39 Commits
Author SHA1 Message Date
wangshaoqing d302614b99 feat: automate tag release pipeline
continuous-integration/drone/tag Build is failing
2026-05-25 11:26:02 +08:00
wangshaoqing 57e4dc72aa feat: switch extension updates to COS 2026-05-25 10:32:39 +08:00
wangshaoqing 8fa9fc4469 docs: add COS update design spec 2026-05-25 10:19:14 +08:00
wangshaoqing 02d9063a11 feat: add extension update check 2026-05-19 18:50:03 +08:00
wangshaoqing 703a095c08 chore: hide export range selector 2026-05-19 17:57:09 +08:00
wangshaoqing 75294ca8c7 chore: simplify export action labels 2026-05-19 17:39:57 +08:00
wangshaoqing 303efcdc8f chore: clarify export labels and tooltips 2026-05-19 16:46:49 +08:00
wangshaoqing 50933af0a6 feat: allow selecting audience export fields 2026-05-19 16:26:04 +08:00
wangshaoqing db95a1f565 chore: refresh internal release package 2026-05-19 13:51:27 +08:00
wangshaoqing 37f7e0b5e6 fix: hydrate id export metrics and remove new tier columns 2026-05-18 19:50:14 +08:00
wangshaoqing 38da39589f feat: export audience profiles by author ids 2026-05-18 19:24:15 +08:00
wangshaoqing 39c4191a95 fix: format business ability estimates 2026-05-18 18:40:34 +08:00
wangshaoqing 249e6a5971 feat: export business ability metrics 2026-05-18 18:27:47 +08:00
wangshaoqing ca9ce02db5 fix: match Xingtu profile endpoints 2026-05-18 17:54:37 +08:00
wangshaoqing c8287e8d8e fix: fill missing audience profile buckets 2026-05-18 17:40:21 +08:00
wangshaoqing 26ae3bb4b6 feat: refine audience profile csv columns 2026-05-18 17:25:58 +08:00
wangshaoqing 66bc49d498 feat: add selected audience profile csv export 2026-05-18 16:59:05 +08:00
wangshaoqing 03c2fe0cc7 fix: restore batch submit base url 2026-05-11 10:25:55 +08:00
wangshaoqing b0d615ab6c Merge remote-tracking branch 'origin/main' 2026-05-11 10:23:29 +08:00
wangshaoqing 1de508f2c7 feat: merge market core user id flow 2026-05-11 10:22:45 +08:00
wxs 577f6bb0bd feat: map market core user id to batch author uid 2026-05-09 14:14:24 +08:00
wangshaoqing 2e049ef718 fix: finalize market alignment release 2026-05-09 12:06:19 +08:00
wangshaoqing a5aad4f165 chore: add build artifacts and test files to .gitignore 2026-05-08 00:44:39 +08:00
wangshaoqing 02be56888a fix: align market selection checkboxes with rows
- Fix checkbox column misalignment with creator list items
- Add syncContentCellHeight to keep selection cells aligned with native rows
- Add MarketAlignmentDiagnostic for debugging alignment issues
- Update documentation to clarify dist-release folder usage
- Add npm run build:release step to colleague setup guide
2026-05-08 00:39:15 +08:00
wangshaoqing ecfe136628 docs: add git clone guide for colleagues 2026-05-07 18:49:08 +08:00
wangshaoqing 09c106e020 docs: add distribution build and simplified user guide
- Include dist-release/ and release/ for direct colleague use
- Add beginner-friendly installation guide
- Update .gitignore to track distribution builds
2026-05-07 18:48:42 +08:00
wangshaoqing 3992d4c325 fix: stabilize selected batch submit state 2026-04-30 16:45:14 +08:00
wangshaoqing b308b49368 Add Chinese project and user documentation 2026-04-29 18:46:17 +08:00
wangshaoqing 376c7b510e Replace batch prompt with custom dialog 2026-04-29 17:23:04 +08:00
wangshaoqing 37e29bd6b8 Prepare internal extension distribution 2026-04-29 16:58:35 +08:00
wangshaoqing 4f1f80b79b Style plugin actions and clean local artifacts 2026-04-29 13:49:02 +08:00
wangshaoqing b1f2db8552 Update batch submit backend IP 2026-04-29 13:24:04 +08:00
wangshaoqing 07d1dffe78 Fix silent market CSV export 2026-04-29 11:33:00 +08:00
wangshaoqing fe60253cd3 fix: align market selection checkboxes 2026-04-24 22:10:16 +08:00
wangshaoqing 7da1bcf255 feat: filter market export by selected creators 2026-04-23 18:58:59 +08:00
wangshaoqing 96e93628bd feat: track selected market creators 2026-04-23 18:27:14 +08:00
wangshaoqing 91d8347b76 feat: add market row selection column 2026-04-23 18:20:52 +08:00
wangshaoqing 45e5bb781b docs: add market selection export plan 2026-04-23 18:15:22 +08:00
wangshaoqing 06737918bc docs: add market selection export design 2026-04-23 18:13:35 +08:00
100 changed files with 22203 additions and 412 deletions
+34
View File
@@ -0,0 +1,34 @@
kind: pipeline
type: docker
name: release-tag
trigger:
event:
- tag
steps:
- name: install
image: node:20-alpine
commands:
- npm ci
- name: test
image: node:20-alpine
depends_on:
- install
commands:
- npm test
- name: release
image: node:20-alpine
depends_on:
- test
environment:
COS_BUCKET: wksgx-1343191620
COS_REGION: ap-nanjing
COS_SECRET_ID:
from_secret: cos_secret_id
COS_SECRET_KEY:
from_secret: cos_secret_key
commands:
- npm run release:tag
+28
View File
@@ -1,4 +1,32 @@
.worktrees/ .worktrees/
.old-reference/ .old-reference/
.local/
dist/ dist/
# dist-release/
# release/
node_modules/ node_modules/
# Build artifacts
dist-release/
dist-release.pem
dist-release.crx
# Playwright test artifacts
playwright-report/
test-results/
e2e-tests/
playwright.config.js
# Debug scripts
scripts/debug-*.mjs
# Local debug captures
after-export-requests.txt
before-export-requests.txt
network-after-page2.txt
# Unrelated local planning artifacts
docs/superpowers/plans/2026-04-25-professional-capability-exam-guide.md
docs/superpowers/specs/2026-04-25-professional-capability-exam-guide-design.md
externaldocs/2026-04-25-专业能力测试冲刺讲义.html
externaldocs/2026-04-25-专业能力测试冲刺讲义.pdf
+185 -70
View File
@@ -1,85 +1,200 @@
# Star Chart Search Enhancer # 星图增强插件
Chrome MV3 extension for the Xingtu creator market page. 这是一个供公司内部使用的 Chrome MV3 插件,用于增强巨量星图达人市场页面的使用体验。
## Development 主要功能:
- 在星图达人列表页补充插件侧数据列
- 支持勾选部分达人后导出 CSV
- 支持将达人数据提交为批次
- 集成 Logto 登录
- 支持内部压缩包分发后通过 `Load unpacked` 安装
当前固定扩展 ID
- `pkjopdibdnomhogjheclhnknmejccffg`
---
## 一、项目目录
- `src/`
- 插件源码
- `dist/`
- 开发构建产物
- `dist-release/`
- 内部分发构建产物
- `release/`
- 打包后的内部交付压缩包
- `docs/`
- 项目说明文档
- `tests/`
- 自动化测试
- `scripts/`
- 构建和打包脚本
---
## 二、开发环境
安装依赖:
```bash
npm install
```
运行测试:
```bash
npm test
```
开发构建:
```bash
npm run build
```
说明:
- `npm run build` 会生成开发版到 `dist/`
- 开发版包含本地调试需要的宽权限
---
## 三、内部交付构建
生成内部使用构建:
```bash
npm run build:release
```
生成内部压缩包:
```bash
npm run package:internal
```
生成更新清单:
```bash
npm run write:latest
```
生成结果:
- 构建目录:`dist-release/`
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
- 更新清单:`release/latest.json`
说明:
- 这个压缩包不是给 Chrome 商店上传的
- 它是发给公司内部同事使用的交付包
- 同事收到后需要解压,再到 `chrome://extensions``Load unpacked`
- COS 发布时,`latest.json` 放在 `star-chart-search-enhancer/latest.json`,ZIP 和 PDF 放在对应版本目录下
- 打 tag 后会触发 Drone 发布,推荐格式:`0.MMDD.N`
---
## 四、插件安装方式
本项目当前采用公司内部手工安装方式:
1. 解压内部压缩包
2. 打开 `chrome://extensions`
3. 打开右上角 `开发者模式`
4. 点击 `加载已解压的扩展程序`
5. 选择解压后的插件文件夹
安装后请确认扩展 ID 是:
- `pkjopdibdnomhogjheclhnknmejccffg`
---
## 五、认证与配置
插件使用 Logto 登录。
认证配置位于:
- `src/shared/auth-config.ts`
当前主要配置包括:
- `logtoEndpoint`
- `appId`
- `apiResource`
- `scopes`
说明:
- popup 中的开发调试面板默认关闭
- 如果需要本地调试受保护接口,可以手动把 `enableDevAuthPanel` 改为 `true`
---
## 六、批次提交说明
提交批次时,前端当前会提交以下核心字段:
- `logtoUserId`
- `creatorName`
- `resource`
- `batchName`
- `createdAt`
- `authors`
说明:
- `batchId` 不再由前端生成
- 现在由后端生成 7 位数字批次 ID
---
## 七、重要文档
给内部同事的安装与使用说明:
- `docs/aigc-user-guide.md`
内部压缩包分发说明:
- `docs/internal-extension-distribution.md`
---
## 八、常用命令
```bash ```bash
npm install npm install
npm test npm test
npm run build npm run build
npm run build:release
npm run package:internal
``` ```
## Load The Extension ---
1. Run `npm run build` ## 九、维护注意事项
2. Open `chrome://extensions`
3. Enable developer mode
4. Choose `Load unpacked`
5. Select the `dist/` directory
## Current Scope - 扩展 ID 已通过 `manifest.key` 固定
- 不要泄露本地私钥文件 `.local/extension-key.pem`
- 如果后端地址发生变化,需要同步更新:
- `scripts/manifest.mjs`
- 对应后端配置文件
- 相关文档
- Adds two after-search-rate columns to the Xingtu market list ---
- Adds a popup-based Logto auth entry
- Hydrates the current page immediately
- Provides plugin-owned filter, sort, and CSV export controls
- Gates the market tools until auth is available
- Triggers full-scan flow only when filter, sort, or export is used
## Auth Configuration ## 十、当前状态
The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`. 当前项目已经支持:
Replace these before real sign-in testing:
- `logtoEndpoint` - 新固定扩展 ID
- `appId` - 内部压缩包分发
- `apiResource` - 自定义批次名称弹窗
- Any extra scopes beyond `openid`, `profile`, and `offline_access` - 后台静默导出
- 批次提交不再由前端生成 `batchId`
The popup dev panel is controlled by `enableDevAuthPanel`.
## Popup Behavior
1. Load the unpacked extension from `dist/`
2. Click the extension icon
3. Confirm the popup shows `登录 Logto` when unauthenticated
4. After real Logto config is added, use the popup to sign in and sign out
## Protected API Mock Test
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
2. Run `npm run mock:protected-api`
3. Run `npm run build`
4. Reload the unpacked extension from `dist/`
5. Open the popup and log in
6. Click `测试受保护接口`
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
## Batch Submit Mock Test
1. Run `npm run mock:protected-api`
2. Run `npm run build`
3. Reload the unpacked extension from `dist/`
4. Open `https://xingtu.cn/ad/creator/market`
5. Choose an export range in the plugin toolbar
6. Click `提交批次`
7. Enter a batch name in the browser prompt
8. Confirm the toolbar shows `批次提交成功`
9. Confirm the mock batch response accepts the payload and reports the submitted `batchId`
## Market Auth Gate
When the market page is opened without a valid auth state, the content script renders
`请先登录插件` and does not boot the filter, sort, or export toolbar.
## Manual Verification
1. Load the unpacked extension from `dist/`
2. Open `https://xingtu.cn/ad/creator/market`
3. Confirm the page shows the auth gate until login is available
4. After authentication is wired, confirm the two new columns appear
5. Confirm current-page rows move through loading and then render values or failure states
6. Apply a threshold filter and confirm the list hides unmatched rows
7. Apply a sort and confirm row order changes
8. Export CSV and confirm the file includes plugin status and after-search-rate fields
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 822 B

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
"use strict";
(() => {
// src/shared/rate-normalizer.ts
function normalizeFractionRateDisplay(value) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return null;
}
const percentageValue = numericValue * 100;
return `${trimTrailingZeros(percentageValue.toFixed(6))}%`;
}
function trimTrailingZeros(value) {
return value.replace(/\.?0+$/, "");
}
// src/content/market/market-list-request-snapshot.ts
var MARKET_REQUEST_SNAPSHOT_ATTRIBUTE = "data-sces-market-request-snapshot";
function writeMarketListRequestSnapshot(document2, snapshot) {
document2.documentElement.setAttribute(
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
JSON.stringify(snapshot)
);
}
// src/content/market/market-list-row.ts
var PAGE_NUMBER_KEYS = [
"currentPage",
"page",
"pageNo",
"pageNum",
"page_no",
"page_num"
];
var PAGE_SIZE_KEYS = [
"limit",
"pageSize",
"page_size",
"size"
];
var TOTAL_COUNT_KEYS = [
"total",
"totalCount",
"total_count"
];
var TOTAL_PAGE_KEYS = [
"pageCount",
"page_count",
"totalPage",
"totalPages",
"total_page",
"total_pages"
];
function mapMarketListRow(row) {
const attributeDatas = readMarketAttributeDatas(row);
const singleVideoAfterSearchRate = normalizeMarketListRate(
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
);
return {
authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "",
authorName: readString(readMarketFieldValue(row, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ?? "",
coreUserId: readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ?? void 0,
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
hasDirectRatesSource: true,
location: readMarketLocation(row, attributeDatas),
price21To60s: readMarketPrice21To60s(row, attributeDatas),
rates: singleVideoAfterSearchRate ? {
singleVideoAfterSearchRate
} : void 0
};
}
function parseMarketListResponse(payload) {
const container = findMarketListContainer(payload);
if (!container) {
return null;
}
const marketList = readMarketListArray(container);
if (!marketList) {
return null;
}
return {
currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? void 0,
pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? void 0,
records: marketList.map((row) => isRecord(row) ? mapMarketListRow(row) : null).filter(
(row) => row !== null && Boolean(row.authorId || row.authorName)
),
totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? void 0,
totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? void 0
};
}
function findMarketListContainer(value) {
const queue = [value];
while (queue.length > 0) {
const current = queue.shift();
if (!isRecord(current)) {
continue;
}
if (readMarketListArray(current)) {
return current;
}
Object.values(current).forEach((entry) => {
queue.push(unwrapVueRef(entry));
});
}
return null;
}
function readMarketListArray(record) {
const marketList = unwrapVueRef(record.marketList);
if (Array.isArray(marketList)) {
return marketList;
}
const authors = unwrapVueRef(record.authors);
if (Array.isArray(authors)) {
return authors;
}
return null;
}
function unwrapVueRef(value) {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function readMarketAttributeDatas(record) {
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
}
function readMarketFieldValue(record, attributeDatas, field) {
return record[field] ?? attributeDatas[field];
}
function readString(value) {
return typeof value === "string" ? value : null;
}
function normalizeMarketListRate(value) {
if (typeof value === "number") {
return normalizeFractionRateDisplay(String(value));
}
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
}
function normalizeExportCellText(value) {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function buildMarketExportFieldFallbacks(record, attributeDatas) {
const exportFields = {};
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
const authorType = buildMarketAuthorType(record, attributeDatas);
const contentTheme = buildMarketContentTheme(record, attributeDatas);
const connectedUsers = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
);
const followerCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
);
const expectedCpm = formatDecimalDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
);
const expectedPlayCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
);
const interactionRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
);
const finishRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
);
const burstRate = readBurstRateDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
);
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
assignExportField(exportFields, "\u8FBE\u4EBA\u4FE1\u606F", authorInfo);
assignExportField(exportFields, "\u4EE3\u8868\u89C6\u9891", representativeVideo);
assignExportField(exportFields, "\u8FBE\u4EBA\u7C7B\u578B", authorType);
assignExportField(exportFields, "\u5185\u5BB9\u4E3B\u9898", contentTheme);
assignExportField(exportFields, "\u8FDE\u63A5\u7528\u6237\u6570", connectedUsers);
assignExportField(exportFields, "\u7C89\u4E1D\u6570", followerCount);
assignExportField(exportFields, "\u9884\u671FCPM", expectedCpm);
assignExportField(exportFields, "\u9884\u671F\u64AD\u653E\u91CF", expectedPlayCount);
assignExportField(exportFields, "\u4E92\u52A8\u7387", interactionRate);
assignExportField(exportFields, "\u5B8C\u64AD\u7387", finishRate);
assignExportField(exportFields, "\u7206\u6587\u7387", burstRate);
assignExportField(exportFields, "21-60s\u62A5\u4EF7", price21To60s);
return Object.keys(exportFields).length > 0 ? exportFields : void 0;
}
function assignExportField(exportFields, key, value) {
if (hasTextValue(value)) {
exportFields[key] = value;
}
}
function hasTextValue(value) {
return Boolean(value && value.trim().length > 0);
}
function buildMarketAuthorInfo(record, attributeDatas) {
const nickname = readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? "";
const parts = [
nickname,
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
].filter((value) => Boolean(value));
return parts.length > 0 ? parts.join(" ") : void 0;
}
function buildMarketAuthorType(record, attributeDatas) {
const tagsRelation = readRecordLike(
readMarketFieldValue(record, attributeDatas, "tags_relation")
);
if (tagsRelation) {
const primaryTag = Object.keys(tagsRelation)[0];
if (hasTextValue(primaryTag)) {
return primaryTag;
}
}
return void 0;
}
function buildMarketContentTheme(record, attributeDatas) {
const themes = readStringArray(
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
);
if (themes.length === 0) {
return void 0;
}
if (themes.length <= 2) {
return themes.join(" ");
}
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
}
function readMarketLocation(record, attributeDatas) {
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? void 0;
}
function readMarketPrice21To60s(record, attributeDatas) {
return formatCurrencyValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
);
}
function readMarketRepresentativeVideo(record, attributeDatas) {
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
for (const item of items) {
if (!isRecord(item)) {
continue;
}
const title = readString(item.title);
if (hasTextValue(title)) {
return normalizeExportCellText(title);
}
}
return void 0;
}
function readMarketGenderLabel(value) {
const rawValue = typeof value === "number" ? String(value) : readString(value);
if (rawValue === "1") {
return "\u7537";
}
if (rawValue === "2") {
return "\u5973";
}
return void 0;
}
function readBurstRateDisplay(value) {
if (value === null) {
return void 0;
}
if (value <= 0) {
return "-";
}
return formatFractionPercent(value);
}
function formatCurrencyValue(value) {
if (value === null) {
return void 0;
}
return `\xA5${value.toLocaleString("en-US", {
maximumFractionDigits: 0
})}`;
}
function formatWanValue(value) {
if (value === null) {
return void 0;
}
return `${formatDecimalWithGrouping(value / 1e4)}w`;
}
function formatFractionPercent(value) {
if (value === null) {
return void 0;
}
return `${formatDecimalDisplay(value * 100)}%`;
}
function formatDecimalDisplay(value) {
if (value === null) {
return void 0;
}
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
useGrouping: false
});
}
function formatDecimalWithGrouping(value) {
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0
});
}
function readNumericValue(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
return null;
}
function readStringArray(value) {
if (Array.isArray(value)) {
return value.filter((item) => typeof item === "string");
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue) ? parsedValue.filter((item) => typeof item === "string") : [];
} catch {
return [];
}
}
return [];
}
function readArrayLike(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue) ? parsedValue : [];
} catch {
return [];
}
}
return [];
}
function readRecordLike(value) {
if (isRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return isRecord(parsedValue) ? parsedValue : null;
} catch {
return null;
}
}
return null;
}
function readKnownNumber(record, keys) {
for (const key of keys) {
const value = readNumericValue(record[key]);
if (value !== null) {
return value;
}
}
return void 0;
}
function readKnownNumberDeep(value, keys) {
if (!isRecord(value)) {
return null;
}
const directValue = readKnownNumber(value, keys);
if (typeof directValue === "number") {
return directValue;
}
for (const nestedValue of Object.values(value)) {
const candidate = readKnownNumberDeep(unwrapVueRef(nestedValue), keys);
if (typeof candidate === "number") {
return candidate;
}
}
return null;
}
// src/content/market/page-bridge.ts
var BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
var MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
var SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
installMarketPageBridge();
function installMarketPageBridge() {
if (window[BRIDGE_MARKER]) {
syncSerializedMarketRows();
return;
}
window[BRIDGE_MARKER] = true;
installMarketRequestSnapshotBridge();
syncSerializedMarketRows();
const observer = new MutationObserver(() => {
syncSerializedMarketRows();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setInterval(() => {
syncSerializedMarketRows();
}, 1e3);
}
function installMarketRequestSnapshotBridge() {
installFetchSnapshotBridge();
installXmlHttpRequestSnapshotBridge();
}
function syncSerializedMarketRows() {
if (typeof document === "undefined") {
return;
}
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if (document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== nextSerializedRows) {
document.documentElement.setAttribute(
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
nextSerializedRows
);
}
}
function installFetchSnapshotBridge() {
if (typeof window.fetch !== "function") {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const requestSnapshot = readFetchSnapshot(input, init);
const response = await originalFetch(input, init);
if (requestSnapshot) {
const clonedResponse = response.clone();
void captureMarketSnapshotFromResponse(
requestSnapshot,
() => clonedResponse.json()
);
}
return response;
};
}
function installXmlHttpRequestSnapshotBridge() {
const OriginalXmlHttpRequest = window.XMLHttpRequest;
if (!OriginalXmlHttpRequest) {
return;
}
const originalOpen = OriginalXmlHttpRequest.prototype.open;
const originalSend = OriginalXmlHttpRequest.prototype.send;
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
OriginalXmlHttpRequest.prototype.open = function(method, url, ...rest) {
this.__scesMarketSnapshot = {
headers: {},
method,
url: String(url)
};
return originalOpen.call(this, method, url, ...rest);
};
OriginalXmlHttpRequest.prototype.setRequestHeader = function(name, value) {
this.__scesMarketSnapshot?.headers && (this.__scesMarketSnapshot.headers[name] = value);
return originalSetRequestHeader.call(this, name, value);
};
OriginalXmlHttpRequest.prototype.send = function(body) {
const snapshotState = this.__scesMarketSnapshot;
if (snapshotState) {
snapshotState.body = typeof body === "string" ? body : void 0;
this.addEventListener("load", () => {
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
return;
}
void captureMarketSnapshotFromResponse(
snapshotState,
async () => JSON.parse(this.responseText)
);
});
}
return originalSend.call(this, body);
};
}
async function captureMarketSnapshotFromResponse(snapshot, readPayload) {
if (!isMarketSearchRequest(snapshot.url)) {
return;
}
try {
const payload = await readPayload();
if (!parseMarketListResponse(payload)) {
return;
}
writeMarketListRequestSnapshot(document, {
body: snapshot.body,
headers: snapshot.headers,
method: snapshot.method,
url: snapshot.url
});
} catch {
}
}
function readSerializedMarketRows() {
const marketList = readMarketList();
return marketList.map((row) => {
const attributeDatas = isRecord2(row.attribute_datas) ? row.attribute_datas : {};
const singleVideoAfterSearchRate = readNormalizedFractionRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId: readString2(row.star_id) ?? readString2(attributeDatas.id) ?? "",
authorName: readString2(attributeDatas.nickname) ?? readString2(row.nick_name) ?? "",
coreUserId: readString2(attributeDatas.core_user_id) ?? void 0,
singleVideoAfterSearchRate
};
}).filter((row) => Boolean(row.authorId || row.authorName));
}
function readFetchSnapshot(input, init) {
const request = input instanceof Request ? input : null;
const method = init?.method ?? request?.method ?? "GET";
const url = request?.url ?? String(input);
const body = typeof init?.body === "string" ? init.body : typeof request?.bodyUsed === "boolean" && request.bodyUsed ? void 0 : void 0;
const headers = serializeHeaders(init?.headers ?? request?.headers);
return {
body,
headers,
method,
url
};
}
function serializeHeaders(headers) {
if (!headers) {
return void 0;
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)])
);
}
function readMarketList() {
if (typeof document === "undefined") {
return [];
}
const marketRoot = document.querySelector(".base-author-list");
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef2(value);
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
return candidate;
}
if (!isRecord2(candidate) || !Array.isArray(candidate.marketList)) {
continue;
}
if (looksLikeMarketList(candidate.marketList)) {
return candidate.marketList;
}
}
return [];
}
function isMarketSearchRequest(url) {
return url === MARKET_SEARCH_REQUEST_PATH || url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.endsWith(MARKET_SEARCH_REQUEST_PATH);
}
function looksLikeMarketList(value) {
const firstRow = value[0];
return isRecord2(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
}
function unwrapVueRef2(value) {
if (isRecord2(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord2(value) {
return typeof value === "object" && value !== null;
}
function readString2(value) {
return typeof value === "string" ? value : null;
}
function readNormalizedFractionRate(value) {
return typeof value === "string" ? normalizeFractionRateDisplay(value) ?? void 0 : void 0;
}
})();
+59
View File
@@ -0,0 +1,59 @@
{
"action": {
"default_icon": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png"
},
"default_popup": "popup/index.html"
},
"background": {
"service_worker": "background/index.js"
},
"content_scripts": [
{
"js": [
"content/index.js"
],
"matches": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*"
],
"run_at": "document_start"
}
],
"description": "Bootstraps the Xingtu creator market content script.",
"icons": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
"manifest_version": 3,
"name": "Star Chart Search Enhancer",
"permissions": [
"downloads",
"identity",
"storage"
],
"web_accessible_resources": [
{
"matches": [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
],
"resources": [
"content/market-page-bridge.js"
]
}
],
"version": "0.2.0421.2",
"host_permissions": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*",
"https://*/*"
]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title>
</head>
<body>
<main id="app"></main>
<script src="./index.js"></script>
</body>
</html>
+422
View File
@@ -0,0 +1,422 @@
"use strict";
(() => {
// src/popup/view.ts
function renderLoggedOut(root, error) {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>\u767B\u5F55\u540E\u624D\u80FD\u4F7F\u7528\u661F\u56FE\u589E\u5F3A\u529F\u80FD</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button">\u767B\u5F55 Logto</button>
</section>
`;
}
function renderLoggedIn(root, authState) {
const userInfo = authState.userInfo;
root.innerHTML = `
<section data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1>
<p>\u5DF2\u767B\u5F55</p>
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
<p>${userInfo?.email ?? ""}</p>
<section data-popup-update="root">
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
</section>
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
</section>
`;
}
function renderUpdateStatus(root, options) {
const container = root.querySelector('[data-popup-update="root"]');
if (!container) {
return;
}
if (options.status === "checking") {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
`;
return;
}
if (options.status === "error") {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u6682\u65F6\u65E0\u6CD5\u68C0\u67E5\u66F4\u65B0</p>
<p>\u5982\u679C\u9700\u8981\u65B0\u7248\uFF0C\u8BF7\u8054\u7CFB\u7EF4\u62A4\u540C\u4E8B\u83B7\u53D6\u66F4\u65B0\u5305\u3002</p>
`;
return;
}
if (options.status === "latest" || !options.manifest) {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C</p>
`;
return;
}
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u53D1\u73B0\u65B0\u7248\u672C\uFF1A${options.manifest.latestVersion}</p>
${renderReleaseNotes(options.manifest.releaseNotes)}
<button type="button" data-popup-download-update="button">\u4E0B\u8F7D\u66F4\u65B0\u5305</button>
<button type="button" data-popup-download-guide="button">\u4E0B\u8F7D\u4F7F\u7528\u8BF4\u660E</button>
<p data-popup-update-download-status="text">\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002</p>
`;
}
function setUpdateDownloadStatus(root, value) {
const output = root.querySelector('[data-popup-update-download-status="text"]');
if (!output) {
return;
}
output.textContent = value;
}
function renderReleaseNotes(releaseNotes) {
if (releaseNotes.length === 0) {
return "";
}
return `
<ul>
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
</ul>
`;
}
function escapeHtml(value) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function renderDevPanel(root, authState) {
const panel = root.ownerDocument.createElement("section");
panel.dataset.popupDevPanel = "root";
panel.innerHTML = `
<h2>dev auth panel</h2>
<p>resource: ${authState.resource ?? ""}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
<p>error: ${authState.lastError ?? ""}</p>
<button type="button" data-popup-test-protected-api="button">\u6D4B\u8BD5\u53D7\u4FDD\u62A4\u63A5\u53E3</button>
<pre data-popup-protected-api-result="output"></pre>
`;
root.appendChild(panel);
}
function setProtectedApiResult(root, value) {
const output = root.querySelector(
'[data-popup-protected-api-result="output"]'
);
if (!output) {
return;
}
output.textContent = value;
}
// src/shared/auth-config.ts
var defaultAuthConfig = {
apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: false,
logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
};
function readAuthConfig(overrides = {}) {
const nextConfig = {
...defaultAuthConfig,
...overrides
};
if (!nextConfig.logtoEndpoint.trim()) {
throw new Error("auth config logtoEndpoint is required");
}
if (!nextConfig.appId.trim()) {
throw new Error("auth config appId is required");
}
if (!nextConfig.apiResource.trim()) {
throw new Error("auth config apiResource is required");
}
return nextConfig;
}
// src/shared/auth-messages.ts
function isAuthResponseMessage(value) {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value;
if (candidate.ok === false) {
return candidate.type === "auth:error" && typeof candidate.error === "string";
}
if (candidate.ok !== true || typeof candidate.type !== "string") {
return false;
}
if (candidate.type === "auth:ack") {
return true;
}
if (candidate.type === "auth:token") {
return Boolean(
candidate.value && typeof candidate.value === "object" && typeof candidate.value.accessToken === "string"
);
}
if (candidate.type === "auth:state") {
return Boolean(
candidate.value && typeof candidate.value === "object" && typeof candidate.value.isAuthenticated === "boolean"
);
}
return false;
}
// src/shared/protected-api-client.ts
function createProtectedApiClient(options) {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async loadProtectedMockData() {
const token = await readAccessToken(options.sendMessage);
const response = await fetchImpl(
new URL("/api/mock/protected", options.baseUrl).toString(),
{
headers: {
Authorization: `Bearer ${token}`
},
method: "GET"
}
);
if (response.status === 401 || response.status === 403) {
throw new Error("protected api unauthorized");
}
if (!response.ok) {
throw new Error(`protected api request failed: ${response.status}`);
}
return response.json();
}
};
}
async function readAccessToken(sendMessage) {
const response = await sendMessage({ type: "auth:get-access-token" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:token" || !response.value.accessToken.trim()) {
throw new Error("protected api token unavailable");
}
return response.value.accessToken;
}
// src/shared/update-check.ts
function compareExtensionVersions(left, right) {
const leftParts = parseVersionParts(left);
const rightParts = parseVersionParts(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] ?? 0;
const rightValue = rightParts[index] ?? 0;
if (leftValue !== rightValue) {
return leftValue - rightValue;
}
}
return 0;
}
function parseUpdateManifest(value) {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value;
if (!isVersionString(candidate.latestVersion) || !isVersionString(candidate.minSupportedVersion) || !isHttpsUrl(candidate.zipUrl) || !isHttpsUrl(candidate.guideUrl) || typeof candidate.publishedAt !== "string" || !Array.isArray(candidate.releaseNotes) || !candidate.releaseNotes.every((note) => typeof note === "string")) {
return null;
}
return {
guideUrl: candidate.guideUrl,
latestVersion: candidate.latestVersion,
minSupportedVersion: candidate.minSupportedVersion,
publishedAt: candidate.publishedAt,
releaseNotes: candidate.releaseNotes,
zipUrl: candidate.zipUrl
};
}
async function fetchUpdateManifest(manifestUrl, fetchImpl = fetch) {
const response = await fetchImpl(manifestUrl, {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`update manifest request failed: ${response.status}`);
}
const manifest = parseUpdateManifest(await response.json());
if (!manifest) {
throw new Error("update manifest is invalid");
}
return manifest;
}
function parseVersionParts(value) {
return value.split(".").map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
}
function isVersionString(value) {
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
}
function isHttpsUrl(value) {
if (typeof value !== "string") {
return false;
}
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
// src/shared/update-config.ts
var UPDATE_MANIFEST_URL = "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
// src/popup/index.ts
async function bootPopup(options = {}) {
const currentDocument = options.document ?? document;
const popupConfig = readAuthConfig(options.config);
const currentVersion = options.currentVersion ?? readCurrentVersion();
const root = currentDocument.querySelector("#app");
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
throw new Error("popup root #app is required");
}
const sendMessage = options.sendMessage ?? ((message) => Promise.resolve(
globalThis.chrome?.runtime?.sendMessage?.(message)
));
const fetchProtectedApi = options.fetchProtectedApi ?? createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
sendMessage
}).loadProtectedMockData;
const fetchUpdateManifest2 = options.fetchUpdateManifest ?? (() => fetchUpdateManifest(
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
));
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
currentVersion,
fetchUpdateManifest: fetchUpdateManifest2
});
}
async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, updateOptions) {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
return;
}
if (!response.value.isAuthenticated) {
renderLoggedOut(root, response.value.lastError);
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-in" },
fetchProtectedApi,
updateOptions
});
});
return;
}
renderLoggedIn(root, response.value);
void runUpdateCheck(root, sendMessage, updateOptions);
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-out" },
fetchProtectedApi,
updateOptions
});
});
if (popupConfig.enableDevAuthPanel) {
renderDevPanel(root, response.value);
root.querySelector('[data-popup-test-protected-api="button"]')?.addEventListener("click", () => {
void runProtectedApiProbe(root, fetchProtectedApi);
});
}
}
async function runAuthAction(root, popupConfig, sendMessage, options) {
const response = await sendMessage(options.actionMessage);
if (isActionError(response)) {
renderLoggedOut(root, response.error);
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, options);
});
return;
}
await renderCurrentAuthState(
root,
popupConfig,
sendMessage,
options.fetchProtectedApi,
options.updateOptions
);
}
function isActionError(response) {
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
}
async function runUpdateCheck(root, sendMessage, options) {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "checking"
});
try {
const manifest = await options.fetchUpdateManifest();
if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "latest"
});
return;
}
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
manifest,
status: "available"
});
bindUpdateDownloadButtons(root, sendMessage, manifest);
} catch {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "error"
});
}
}
function bindUpdateDownloadButtons(root, sendMessage, manifest) {
root.querySelector('[data-popup-download-update="button"]')?.addEventListener("click", () => {
void downloadUpdateAsset(root, sendMessage, {
filename: "star-chart-search-enhancer-internal.zip",
url: manifest.zipUrl
});
});
root.querySelector('[data-popup-download-guide="button"]')?.addEventListener("click", () => {
void downloadUpdateAsset(root, sendMessage, {
filename: "\u661F\u56FE\u589E\u5F3A\u63D2\u4EF6-\u8D85\u7B80\u5355\u5B89\u88C5\u4F7F\u7528\u6307\u5357.pdf",
url: manifest.guideUrl
});
});
}
async function downloadUpdateAsset(root, sendMessage, options) {
setUpdateDownloadStatus(root, "\u6B63\u5728\u4E0B\u8F7D...");
try {
await sendMessage({
filename: options.filename,
type: "update:download",
url: options.url
});
setUpdateDownloadStatus(root, "\u5DF2\u89E6\u53D1\u4E0B\u8F7D\u3002\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002");
} catch (error) {
setUpdateDownloadStatus(
root,
error instanceof Error ? error.message : "\u4E0B\u8F7D\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
);
}
}
function readCurrentVersion() {
const runtime = globalThis.chrome?.runtime;
return runtime?.getManifest?.().version ?? "0.0.0";
}
async function runProtectedApiProbe(root, fetchProtectedApi) {
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
try {
const result = await fetchProtectedApi();
setProtectedApiResult(root, JSON.stringify(result, null, 2));
} catch (error) {
setProtectedApiResult(
root,
error instanceof Error ? error.message : String(error)
);
}
}
if (typeof document !== "undefined") {
void bootPopup();
}
})();
+540
View File
@@ -0,0 +1,540 @@
# 星图增强插件使用说明
适用对象:AIGC 部门同事,默认不需要任何编程基础。
这份说明会教你:
- 如何安装插件
- 如何登录插件
- 如何在星图页面使用导出和提交批次功能
- 如何更新插件
- 遇到问题时应该怎么处理
---
## 一、这是什么
这是一个给巨量星图达人列表页使用的 Chrome 插件。
它的主要作用是:
- 在星图达人列表页面提供增强数据
- 支持勾选部分达人后导出 CSV
- 支持把达人列表提交为批次
插件名称:
- `Star Chart Search Enhancer`
当前固定扩展 ID
- `pkjopdibdnomhogjheclhnknmejccffg`
如果你在 Chrome 扩展页里看到这个 ID,说明装的是正确版本。
---
## 二、安装前准备
开始前请先确认:
1. 你使用的是 `Google Chrome` 浏览器。
2. 你已经拿到了同事发给你的插件压缩包:
- `star-chart-search-enhancer-internal.zip`
3. 你可以正常访问:
- 巨量星图
- 公司登录系统
注意:
- 这个插件不是从 Chrome 网上应用店安装的。
- 你必须手动解压并加载。
---
## 三、第一次安装
### 第 1 步:解压压缩包
找到收到的压缩包:
- `star-chart-search-enhancer-internal.zip`
右键解压。
解压后你会得到一个文件夹。请记住这个文件夹的位置,不要删除。
建议放在:
- 桌面
- 下载目录
- 或者一个不会随手清理掉的位置
不要放在:
- 临时目录
- 会被自动清理的文件夹
原因:
- Chrome 加载的是这个文件夹本身
- 如果文件夹被删了,插件就失效了
### 第 2 步:打开 Chrome 扩展页面
在 Chrome 地址栏输入:
```text
chrome://extensions
```
然后按回车。
### 第 3 步:打开开发者模式
在扩展页面右上角,找到:
- `开发者模式`
把它打开。
### 第 4 步:加载插件
点击左上角或页面上的:
- `加载已解压的扩展程序`
然后选择刚才解压出来的那个文件夹。
选择后,Chrome 会自动加载插件。
### 第 5 步:确认是否安装成功
在扩展列表中找到:
- `Star Chart Search Enhancer`
点击 `详情` 后,确认扩展 ID 是:
- `pkjopdibdnomhogjheclhnknmejccffg`
如果是这个 ID,说明安装的是正确版本。
---
## 四、固定到浏览器工具栏
为了后续登录方便,建议把插件固定到工具栏。
操作方法:
1. 点击 Chrome 右上角的拼图图标
2. 找到 `Star Chart Search Enhancer`
3. 点击右边的图钉
固定后,浏览器右上角就能直接看到插件图标。
---
## 五、如何登录
### 第 1 步:打开插件弹窗
点击 Chrome 右上角工具栏中的插件图标。
### 第 2 步:点击登录
如果当前未登录,你会看到登录按钮。
点击:
- `登录 Logto`
### 第 3 步:完成登录
系统会自动跳转到公司登录流程。
按页面提示完成登录。
### 第 4 步:确认登录成功
登录成功后,再次点击插件图标。
你会看到:
- 已登录状态
- 你的用户名信息
- 退出登录按钮
如果这里能正常显示,说明登录已经成功。
---
## 六、进入星图页面
插件只在巨量星图达人市场页面生效。
请打开类似下面的页面:
```text
https://xingtu.cn/ad/creator/market
```
进入后,等待页面加载完成。
如果你已经登录插件,页面上会出现插件自己的工具栏和增强列。
---
## 七、页面上你会看到什么
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
- `导出选中达人数据` 按钮
- `提交批次` 按钮
- 状态提示文字
- 每一行达人前面的勾选框
其中:
- `导出选中达人数据`:导出已勾选达人,包含内容数据、效果预估、画像等维度
- `提交批次`:把当前选择的数据提交为批次
---
## 八、如何选择达人
页面上每个达人前面都有一个勾选框。
你可以:
- 单独勾选某几个达人
- 勾选表头复选框,快速全选当前页
规则说明:
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
- 导出达人数据必须先勾选达人
- 提交批次时,如果你没有勾选任何达人,则默认处理当前列表范围内的达人
---
## 九、导出达人数据 CSV 的方法
导出达人数据 CSV 用来导出内容数据、效果预估、画像和秒思 api 数据。
### 1. 勾选达人
导出达人数据必须先勾选达人。
原因:
- 导出达人数据会额外读取达人详情页数据
- 为了避免请求太多,只处理你勾选的达人
### 2. 可选:选择需要导出的字段
如果只想导出一部分字段,点击:
- `选择字段`
在弹出的窗口里:
1. 勾选需要的字段
2. 取消不需要的字段
3. 点击 `保存`
基础字段会固定保留,例如:
- 达人ID
- 达人名称
- 导出状态
- 失败原因
保存后,下次再导出 CSV,会自动沿用这次勾选结果,不需要重新勾选。
### 3. 点击导出
点击:
- `导出选中达人数据`
如果已经有达人星图 ID 列表,也可以点击:
- `按星图ID导出`
然后把达人 ID 粘贴进去,每行一个。
### 4. 导出内容
CSV 会包含:
- 内容数据
- 效果预估
- 画像
- 秒思 api 数据
### 5. 等待导出完成
页面上会显示状态,例如:
- `导出中...`
导出完成后,浏览器会自动下载 CSV 文件。
### 5. 去哪里找文件
通常会在浏览器默认下载目录里找到。
如果你不确定下载到了哪里:
1. 打开 Chrome 下载列表
2. 找到最新下载的 CSV 文件
---
## 十一、提交批次的方法
### 1. 如果只想提交部分达人
先勾选想提交的达人。
### 2. 点击提交
点击:
- `提交批次`
### 3. 输入批次名称
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
你会看到:
- 标题
- 输入框
- `取消`
- `确认提交`
建议批次名称写得清楚一些,例如:
- `618达人筛选第一批`
- `食品饮料-KOL测试批次`
- `5月女装达人候选`
### 4. 确认提交
输入后点击:
- `确认提交`
也可以直接按回车提交。
### 5. 提交成功的提示
如果成功,页面状态会显示:
- `批次提交成功`
如果失败,会看到错误提示。
---
## 十二、批次名称填写建议
建议使用容易看懂的命名方式:
- 时间 + 主题
- 项目 + 人群
- 场景 + 顺序编号
推荐示例:
- `2026-05-电商零食达人初筛`
- `AIGC视频合作达人第一批`
- `母婴品类候选达人-第1批`
不推荐示例:
- `测试`
- `aaa`
- `批次1`
原因:
- 后续回看时不容易分辨
- 团队协作时不方便沟通
---
## 十三、如何更新插件
插件弹窗会检查是否有新版本。
### 方法一:从插件弹窗下载新版本
1. 点击浏览器右上角的插件图标
2. 查看 `版本更新` 区域
3. 如果提示发现新版本,点击:
- `下载更新包`
- `下载使用说明`
4. 解压下载到的新版本 zip
5. 打开:
- `chrome://extensions`
6. 找到:
- `Star Chart Search Enhancer`
7. 点击:
- `重新加载`
如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。
### 方法二:收到压缩包后手动更新
当你收到新的插件压缩包时,不需要重新从零安装。
按照下面做:
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
2. 打开:
- `chrome://extensions`
3. 找到:
- `Star Chart Search Enhancer`
4. 点击:
- `重新加载`
### 方法三:重新解压到新文件夹再重新加载
1. 解压新的压缩包
2. 打开:
- `chrome://extensions`
3. 如果扩展还在,先看它当前对应的文件夹是否还是旧目录
4. 如有需要,移除旧插件,再重新 `加载已解压的扩展程序`
更新后请确认扩展 ID 仍然是:
- `pkjopdibdnomhogjheclhnknmejccffg`
如果 ID 不是这个,说明装错版本了。
---
## 十四、常见问题
### 1. 看不到插件按钮
处理方法:
1. 点击浏览器右上角拼图图标
2. 找到 `Star Chart Search Enhancer`
3. 点击图钉固定
### 2. 打开星图页面后没有出现插件工具栏
先检查:
1. 是否已经登录插件
2. 是否打开的是星图达人市场页
3. 插件是否启用
如果还不行:
1. 打开 `chrome://extensions`
2. 找到插件
3. 点击 `重新加载`
4. 刷新星图页面
### 3. 点击登录后没反应或登录失败
先尝试:
1. 关闭登录弹窗
2. 再次点击插件图标
3. 重新登录
如果仍然失败,请把下面信息发给维护同事:
- 出错时间
- 浏览器截图
- 是否能打开星图
- 是否能看到插件图标
### 4. 提交批次失败
请先确认:
1. 你已经登录插件
2. 批次名称不是空的
3. 当前页面数据已经加载出来
如果还是失败,请把:
- 页面提示内容
- 提交时的截图
- 使用的批次名称
发给维护同事。
### 5. 导出 CSV 没下载
先检查:
1. 浏览器是否拦截下载
2. 默认下载目录里是否已有文件
3. Chrome 下载列表里是否能看到记录
### 6. 扩展列表里出现两个同名插件
正确处理方式:
1. 打开 `chrome://extensions`
2. 查看两个扩展的 ID
3. 保留:
- `pkjopdibdnomhogjheclhnknmejccffg`
4. 删除不是这个 ID 的旧版本
---
## 十四、建议的日常使用流程
推荐按下面顺序使用:
1. 打开 Chrome
2. 确认插件已启用
3. 点击插件图标确认登录状态
4. 打开星图达人市场页
5. 等待页面数据加载完成
6. 先勾选需要的人
7. 需要表格时点 `导出选中达人数据`
8. 需要进入后续流程时点 `提交批次`
---
## 十五、遇到问题时请这样反馈
如果需要找维护同事帮忙,请尽量一次性提供以下信息:
- 你在做什么操作
- 出问题的时间
- 页面截图
- 浏览器里看到的提示文字
- 扩展 ID 是否为 `pkjopdibdnomhogjheclhnknmejccffg`
这样能更快定位问题。
---
## 十六、给安装同事的一句话版本
如果你要把最短说明发给同事,可以直接复制下面这段:
```text
1. 解压收到的插件压缩包
2. 打开 chrome://extensions
3. 打开右上角“开发者模式”
4. 点击“加载已解压的扩展程序”
5. 选择解压后的文件夹
6. 确认插件名称是 Star Chart Search Enhancer
7. 确认扩展 ID 是 pkjopdibdnomhogjheclhnknmejccffg
8. 点击右上角插件图标完成登录
9. 打开巨量星图达人市场页面开始使用
```
+83
View File
@@ -0,0 +1,83 @@
# Internal Extension Distribution
## Fixed Extension ID
- Manifest key is fixed in the project.
- The unpacked extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
- Update Logto with:
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/callback`
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/`
- `chrome-extension://pkjopdibdnomhogjheclhnknmejccffg`
## Internal Package
1. Run `npm test`.
2. Run `npm run package:internal`.
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
1. Unzip `star-chart-search-enhancer-internal.zip`.
2. Open `chrome://extensions`.
3. Enable developer mode.
4. Click `Load unpacked`.
5. Select the unzipped folder.
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
## Notes
- Keep `.local/extension-key.pem` private and backed up internally.
- Do not commit or share the private key with people who only need to install the extension.
- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging.
@@ -0,0 +1,181 @@
# Market Selection 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 cross-page creator selection checkboxes so CSV export and batch submission operate on selected creators within the chosen export range, with fallback to the full export range when no selected creators are present in that range.
**Architecture:** Extend the market table DOM sync layer with a leading checkbox column and a current-page header select-all control, then keep selection state in the content controller as a `Set<authorId>`. Reuse the existing export-range pipeline by filtering resolved `MarketRecord[]` after range collection and before CSV generation or batch payload creation.
**Tech Stack:** TypeScript, Chrome MV3 content script, existing DOM sync helpers, Vitest, jsdom
---
## File Map
- Modify: `src/content/market/dom-sync.ts`
- Add checkbox column rendering, row checkbox references, header checkbox references, and checkbox state sync helpers.
- Modify: `src/content/market/types.ts`
- Add minimal selection-related DOM typing if needed.
- Modify: `src/content/market/index.ts`
- Store selected creator ids, respond to row and header checkbox changes, and filter export / batch records with fallback logic.
- Test: `tests/market-dom-sync.test.ts`
- Cover injected checkbox header and row controls for both synthetic and div-grid layouts.
- Test: `tests/market-content-entry.test.ts`
- Cover per-row selection, current-page select-all, cross-page persistence, export filtering, fallback behavior, and batch submission filtering.
### Task 1: Checkbox Column DOM
**Files:**
- Modify: `src/content/market/dom-sync.ts`
- Modify: `src/content/market/types.ts`
- Test: `tests/market-dom-sync.test.ts`
- [ ] **Step 1: Write the failing DOM tests**
Add tests for:
- synthetic tables inject a leading checkbox header cell and one checkbox cell per row
- div-grid tables inject a leading checkbox column and current-page header checkbox
- returned table DOM exposes row checkbox elements and the header checkbox
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: FAIL because the checkbox column and DOM references do not exist yet.
- [ ] **Step 3: Write minimal DOM implementation**
Implement in `src/content/market/dom-sync.ts`:
- a leading selection column key
- row checkbox creation for synthetic rows
- row checkbox column creation for div-grid rows
- header checkbox creation for both layouts
- `MarketRowDom` and `MarketTableDom` additions for checkbox references
Keep the new column visually narrow and place it before the author column.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/dom-sync.ts src/content/market/types.ts tests/market-dom-sync.test.ts
git commit -m "feat: add market row selection column"
```
### Task 2: Controller Selection State
**Files:**
- Modify: `src/content/market/index.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing controller tests**
Add tests for:
- clicking a row checkbox stores that creator selection
- selection survives a page change and re-render
- header checkbox selects all visible creators on the current page
- header checkbox clears all visible creators on the current page
- header checkbox becomes indeterminate when only part of the current page is selected
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
Expected: FAIL because the controller does not yet track selection or bind checkbox events.
- [ ] **Step 3: Write minimal controller implementation**
Implement in `src/content/market/index.ts`:
- `selectedAuthorIds: Set<string>`
- event listeners for row checkboxes and the header checkbox
- current-page checkbox state sync during `applyCurrentView()`
- current-page select-all behavior scoped only to visible rows from the current DOM table
Do not persist selection in `resultStore`. Keep it controller-local and keyed by `authorId`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/index.ts tests/market-content-entry.test.ts
git commit -m "feat: track selected market creators"
```
### Task 3: Export and Batch Filtering
**Files:**
- Modify: `src/content/market/index.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing filtering tests**
Add tests for:
- export uses only selected creators when the resolved export range contains selected creators
- export falls back to the full resolved export range when none of the selected creators are inside that resolved range
- batch submit uses the same selected-with-fallback record set
- cross-page selections only apply when those selected creators are inside the chosen export range
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
Expected: FAIL because export and batch submit still use the full resolved range.
- [ ] **Step 3: Write minimal filtering implementation**
Implement in `src/content/market/index.ts`:
- a helper that filters resolved `MarketRecord[]` by `selectedAuthorIds`
- fallback to the original resolved records when the filtered subset is empty
- use this helper in both:
- CSV export before `buildCsv(records)`
- batch submit before `createBatchPayload(...)`
Keep the CSV exporter and batch payload builder unchanged. Only change the records passed into them.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
Expected: PASS
- [ ] **Step 5: Run focused verification**
Run:
- `npm test -- tests/market-dom-sync.test.ts`
- `npm test -- tests/market-content-entry.test.ts`
- `npm run build`
Expected:
- market DOM sync tests pass
- market content controller tests pass
- build exits 0
- [ ] **Step 6: Commit**
```bash
git add src/content/market/index.ts tests/market-content-entry.test.ts
git commit -m "feat: filter market export by selected creators"
```
## Notes for Implementation
- Scope the header checkbox to the current visible page only.
- Recompute header checkbox state after every DOM re-sync and after every selection change.
- Apply selection after export-range resolution, not before. This preserves current range semantics.
- When no selected creators exist inside the resolved export range, keep current behavior and export / submit the whole resolved range.
- Do not modify CSV headers or batch payload shape.
## Final Verification
- [ ] `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
- [ ] `npm test -- tests/backend-metrics-client.test.ts tests/batch-submit-client.test.ts tests/background-index.test.ts tests/batch-payload.test.ts`
- [ ] `npm run build`
- [ ] Manual browser check:
- select a few creators on page 1 and page 3
- export `当前页` from page 1 and confirm only selected page 1 creators export
- export `前5页` and confirm selected creators across pages are included
- clear current page via header checkbox and confirm other-page selections remain
@@ -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,183 @@
# Market Selection Export Design
## Goal
Add row-level selection checkboxes to the market table so users can selectively export CSV data and submit batches for chosen creators, while preserving the existing export range workflow.
## Confirmed Decisions
- Add one checkbox column before each creator row.
- Add a header checkbox that selects or clears only the creators visible on the current page.
- Selection state persists across pagination.
- Selection affects both:
- CSV export
- batch submission
- If the current export range contains any selected creators, export and submit only those selected creators within that range.
- If the current export range contains no selected creators, fall back to all creators in the current export range.
- Keep the existing export range selector and current toolbar layout.
- Do not change the CSV column schema.
- Do not change the batch payload shape. Only change which records are included.
## User Experience
### Table Controls
- Each creator row gets a checkbox at the far left.
- The table header gets a tri-state checkbox:
- unchecked: none of the current page creators are selected
- indeterminate: some but not all current page creators are selected
- checked: all current page creators are selected
- Clicking the header checkbox toggles selection for the current visible page only.
### Export and Submit Behavior
- Export still starts from the current range selector:
- current page
- first 5 pages
- custom pages
- all pages
- After range resolution:
- if any creators in that resolved range are selected, use only those selected creators
- if none are selected in that resolved range, use the full resolved range
- Batch submit uses the same filtered creator set as CSV export.
### Status Feedback
- Keep the existing export status area.
- Add lightweight selection feedback in the toolbar status text when helpful, for example:
- `已勾选 7 位达人`
- Do not add extra selection mode toggles or secondary panels.
## Data Model
Selection is UI state, not record data.
- Maintain selection in the controller as a `Set<string>` keyed by `authorId`.
- Do not store selection inside the CSV exporter.
- Do not mutate `MarketRecord` with persistent selection fields unless required for DOM wiring.
- Resolve selection against `authorId` only so sorting, filtering, and row reordering do not break checkbox state.
## DOM Design
### Synthetic Table
- Insert a selectable header cell before the author column.
- Insert one checkbox cell per row.
- Expose the row checkbox and header checkbox through `MarketRowDom` and table DOM helpers.
### Div Grid Table
- Clone a narrow column before the native author column.
- Render checkbox cells aligned to each creator row.
- Render the header checkbox in the sticky header section.
- Reuse the existing plugin section insertion pattern so checkbox layout survives page refresh and plugin re-sync.
## Controller Design
### New Responsibilities
- Track selected creator ids across page changes.
- Re-apply checkbox state after every DOM re-sync.
- Update header checkbox state after:
- row checkbox changes
- header checkbox changes
- pagination changes
- sorting and filtering changes
### Export Flow
Current flow:
- resolve export target
- collect `MarketRecord[]`
- build CSV
New flow:
- resolve export target
- collect `MarketRecord[]`
- filter records by selection-with-fallback rule
- build CSV
### Batch Flow
Current flow:
- resolve export target
- collect `MarketRecord[]`
- build batch payload
- submit
New flow:
- resolve export target
- collect `MarketRecord[]`
- filter records by selection-with-fallback rule
- build batch payload from filtered records
- submit
## Filtering Rule
Given the resolved export records:
1. Find the subset whose `authorId` exists in the selection set.
2. If that subset is non-empty, use it.
3. If that subset is empty, use the original resolved records.
This rule intentionally scopes selection to the chosen export range. For example:
- If the user selected creators on page 1 and page 3
- then exports `当前页` while viewing page 1
- only page 1 selected creators are used
- page 3 selections remain stored for later exports
## File Impact
- Modify: `src/content/market/dom-sync.ts`
- checkbox column insertion
- row and header checkbox lookup
- checkbox state sync helpers
- Modify: `src/content/market/index.ts`
- selection state storage
- event handling
- export and batch record filtering
- Modify: `src/content/market/types.ts`
- minimal DOM type additions if needed
- Test: `tests/market-dom-sync.test.ts`
- checkbox column rendering
- header checkbox presence
- Test: `tests/market-content-entry.test.ts`
- row selection
- current page header select all
- cross-page selection persistence
- export fallback when no selected creators are inside the resolved range
- export filtering when selected creators exist in the resolved range
- batch submit uses the same filtered set
## Risks and Mitigations
### Risk: Selection breaks after reordering
Mitigation:
- key selection only by `authorId`
- never key by row index or DOM order
### Risk: Header checkbox selects hidden or unloaded rows
Mitigation:
- limit header checkbox operations to current page visible row DOMs only
### Risk: User expects selection to always override export range
Mitigation:
- keep selection constrained to the resolved export range
- preserve current range selector semantics
### Risk: Selection UI adds visual clutter
Mitigation:
- use a narrow first column
- follow the host page checkbox look and spacing as closely as practical
## Out of Scope
- Bulk actions beyond export and batch submit
- Selection persistence across browser reloads
- Dedicated “selected only” mode in the toolbar
- Server-side storage of selected creators
@@ -0,0 +1,105 @@
# Market Audience Profile Export Design
## Goal
Add a separate CSV export for selected creators that includes the current market export fields plus audience profile data from each creator detail page's "连接用户" tab.
## User-Approved Decisions
- Add a new toolbar button named `导出画像CSV`.
- Keep the existing `导出CSV` behavior unchanged.
- Only allow the new export when at least one creator row is selected.
- Do not support "export all" for profile data in this change because detail-page data costs extra API/page loads.
- Suggested downloaded filename: `达人连接用户画像_YYYYMMDD_HHmm.csv`.
- Export profile distributions as separate structured CSV columns, not as JSON blobs.
## Data Scope
Each exported row represents one selected creator. Start with the current market CSV columns, then append audience profile columns for:
- 性别分布
- 年龄分布
- 全国省份分布
- 地域占比 TOP10
- 城市等级分布
- 兴趣分布
- 八大人群占比
Fixed distributions should become fixed columns, for example:
- `连接用户-男性占比`
- `连接用户-女性占比`
- `连接用户-18-23占比`
- `连接用户-24-30占比`
- `省份-广东占比`
- `城市等级-一线占比`
- `八大人群-精致妈妈占比`
Ranked distributions should become name/value column pairs:
- `地域TOP1名称`
- `地域TOP1占比`
- ...
- `地域TOP10名称`
- `地域TOP10占比`
- `兴趣TOP1名称`
- `兴趣TOP1占比`
- ...
- `兴趣TOP10名称`
- `兴趣TOP10占比`
Add a `画像抓取状态` column so partial failures are visible in CSV output.
## Data Acquisition
Use an on-demand detail-page probe. The implementation must first confirm the real data source from an authenticated creator detail page:
1. Prefer Xingtu `/gw/api/...` JSON responses if they expose the required profile data.
2. If the API payload is difficult to locate or unstable, read the detail page's Vue/ECharts state from the page context.
3. Avoid screen/OCR parsing and avoid relying on rendered chart pixels.
The export should process selected creators one at a time by default to respect API limits and reduce anti-abuse risk. Cache successful profile results in memory for the current page session.
## UX
Toolbar behavior:
- Add `导出画像CSV` next to the existing export actions.
- Disable the button while any export/submission action is running.
- If no creators are selected, show `请先勾选需要导出画像的达人`.
- While exporting, show progress such as `画像导出中 3/12...`.
- If plugin auth is expired, show `登录已过期,请重新登录`.
- If one creator fails, keep the row in the CSV with `画像抓取状态=失败` and leave profile columns empty.
- If all creators fail, do not download a CSV and show a failure message.
## Architecture
Add the feature as a separate export path instead of extending the existing `导出CSV` action. Reuse current selection state, market record hydration, CSV escaping, and runtime download path.
Proposed units:
- `audience-profile-client`: load and parse audience profile data for one creator detail page.
- `audience-profile-csv`: combine existing market CSV columns with profile-specific columns.
- Toolbar additions: add a button and handler for profile export.
- Controller additions: filter to selected creators, call the profile client serially, build the CSV, then reuse `onCsvReady`.
## Testing
Use TDD. Add focused tests for:
- Auth state expired detection and user-facing expired-login text.
- Toolbar renders and wires the new `导出画像CSV` button.
- New export refuses to run without selected creators.
- Profile CSV expands fixed and ranked distributions into separate columns.
- Controller exports only selected creators and fetches profiles serially.
- Failed creator profile fetches produce a failed row while successful rows still export.
Run focused tests first, then full `npm test`, then `npm run build`.
## Out of Scope
- Unselected/all-page profile export.
- Persistent cross-session profile cache.
- Visual dashboard UI for profile data.
- Changing batch submission payloads.
- OCR or screenshot-based chart extraction.
@@ -0,0 +1,84 @@
# COS Extension Update Design
## Goal
Use COS as the release source for extension updates. When the popup opens, it checks a public `latest.json` on COS. If the COS version is newer than the installed extension version, the popup shows an update card with download actions for the ZIP and the PDF guide, plus manual reload instructions.
## Confirmed Scope
- Update prompt appears only in the extension popup.
- No star chart page banner in this change.
- The user keeps the current manual install flow: download, unzip, replace the folder, then reload in `chrome://extensions`.
## Reusable Implementation
The current repo already has most of the flow:
- `src/shared/update-check.ts` parses the manifest and compares versions.
- `src/popup/index.ts` checks for updates when the popup boots.
- `src/popup/view.ts` renders the update status and download actions.
- `src/background/index.ts` downloads the ZIP/PDF through `chrome.downloads`.
- `scripts/write-latest-manifest.mjs` generates `release/latest.json`.
This change is mostly activation and configuration, not a rewrite.
## Manifest Contract
The public COS manifest must keep these fields:
- `latestVersion`
- `minSupportedVersion`
- `publishedAt`
- `releaseNotes`
- `zipUrl`
- `guideUrl`
Rules:
- All asset URLs must be public HTTPS URLs.
- Version comparison stays numeric dotted comparison.
- Popup logic only needs `latestVersion` to decide whether to show the update card.
- `minSupportedVersion` stays in the manifest for forward compatibility.
## COS Layout
Use a fixed release layout like:
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/latest.json`
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
## User Flow
1. User opens the popup.
2. Popup reads the current extension version.
3. Popup fetches COS `latest.json` with `no-store`.
4. If `latestVersion` is not newer, show “当前已是最新版本”.
5. If `latestVersion` is newer, show “发现新版本” plus release notes.
6. User clicks:
- `下载更新包` for the ZIP
- `下载使用说明` for the PDF
7. Popup shows the manual upgrade instructions after download starts.
## Error Handling
- If the manifest is missing, invalid, or unreachable, the popup should show a non-blocking update error.
- If download fails, the popup should show a download error and keep the plugin usable.
- Update check failures must not block auth or normal plugin behavior.
## Release Process
1. Build the release package.
2. Package the internal ZIP.
3. Generate `latest.json` with the real COS base URL.
4. Upload `latest.json`, the ZIP, and the PDF to the COS folder.
5. Replace the placeholder manifest URL in `src/shared/update-config.ts`.
6. Rebuild and verify the popup update card.
## Out of Scope
- Automatic in-place extension updates.
- Auto-reload after download.
- Star chart page update prompts.
- Chrome Web Store publishing.
@@ -0,0 +1,134 @@
# 📥 从 Git 下载使用插件
> 适合:第一次从 Git 仓库下载的同事
---
## 第一步:安装 Git(如果还没装)
**Mac 用户**
打开终端,输入:
```bash
git --version
```
如果没安装,系统会提示你安装。
**Windows 用户**
1. 访问 https://git-scm.com/download/win
2. 下载并安装(一直点"下一步"即可)
---
## 第二步:下载插件代码
打开终端(Mac)或命令提示符/PowerShellWindows),输入:
```bash
cd Desktop
git clone https://git.internal.intelligrow.cn/wangshaoqing/star-chart-search-enhancer.git
```
等待下载完成,桌面上会出现一个文件夹 `star-chart-search-enhancer`
---
## 第三步:安装插件到 Chrome
1. 打开 Chrome 浏览器
2. 地址栏输入 `chrome://extensions` 并回车
3. 右上角打开 **"开发者模式"**
4. 点击 **"加载已解压的扩展程序"**
5. 选择桌面上的这个路径:
```
star-chart-search-enhancer/dist-release/
```
⚠️ **重要**:必须选择 `dist-release` 这个子文件夹,不要选外层文件夹
✅ 安装成功!你会看到插件卡片。
---
## 第四步:确认扩展 ID
点击插件卡片的 **"详情"**,确认 ID 是:
**`pkjopdibdnomhogjheclhnknmejccffg`**
如果这个 ID 不对,说明下载有问题,请联系我。
---
## 第五步:登录并开始使用
1. 点击 Chrome 右上角的拼图图标 🧩
2. 找到 **Star Chart Search Enhancer**,点击图钉 📌 固定
3. 点击插件图标 → **登录**
4. 完成公司账号登录
5. 打开星图页面开始使用
---
## 🆕 最近修复
当前版本已修复:
- 在星图搜索关键词后,左侧插件勾选框和右侧达人行可能逐行错位的问题
如果你之前已经装过旧版本,请务必按下面的“后续更新”步骤执行一次更新和重新加载。
---
## 🔄 后续更新
当我发布新版本时,你只需要:
```bash
cd Desktop/star-chart-search-enhancer
git pull
npm run build:release
```
然后到 `chrome://extensions` 页面点击插件卡片的 **"重新加载"** 🔄
推荐完整更新顺序:
1. 先关闭星图页面标签页
2. 执行 `git pull`
3. 执行 `npm run build:release`
4. 到 `chrome://extensions` 点击插件卡片的 **"重新加载"**
5. 重新打开星图页面
如果你主要使用的是“达人市场”页面,更新后可以这样快速确认修复是否生效:
1. 打开 `https://xingtu.cn/ad/creator/market`
2. 在搜索框输入关键词,例如 `孕晚期`
3. 向下滚动到达人列表区域
4. 确认左侧勾选框和右侧达人行保持对齐
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择 `dist-release` 文件夹
---
## ❓ 常见问题
**Q: 提示 "git 不是内部或外部命令"**
A: Git 没装好,请先安装 Git。
**Q: 下载后找不到 dist-release 文件夹?**
A: 请确认下载的是最新版本,可以重新执行 `git pull`。
**Q: 加载后扩展 ID 不对?**
A: 请检查是否选择了 `dist-release` 文件夹,而不是外层文件夹。
---
## 📞 需要帮助?
如果以上步骤遇到问题,请截图发给我:
1. 你在哪一步卡住了
2. 完整的错误提示
3. Chrome 扩展页面的截图
@@ -0,0 +1,177 @@
# 🌟 星图增强插件 - 超简单使用指南
> 适合:完全没用过插件的新手 | 阅读时间:3分钟
---
## 📦 第一步:拿到压缩包
你会从同事那里收到一个文件:
**`star-chart-search-enhancer-internal.zip`**
把它保存在桌面上,不要删掉。
---
## 📂 第二步:解压(右键就行)
1. 在桌面上找到这个压缩包
2. **右键** → 选择"解压到当前文件夹"(或"Extract Here"
3. 会多出一个文件夹,名字类似 `star-chart-search-enhancer-internal`
⚠️ **重要**:这个文件夹要一直放在桌面,不要删、不要改名
---
## 🔧 第三步:安装到 Chrome(只需做一次)
1. 打开 **Chrome 浏览器**
2. 在地址栏输入:
```
chrome://extensions
```
然后按回车
3. 右上角找到 **"开发者模式"** → 打开开关(点一下变蓝色)
4. 点击左上角出现的 **"加载已解压的扩展程序"**
5. 选择刚才解压出来的插件文件夹
⚠️ **重要**:如果文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
6. 看到绿色的插件卡片出现,就装好了!
✅ **检查**:点击"详情",确认 ID 是 `pkjopdibdnomhogjheclhnknmejccffg`
---
## 🔑 第四步:登录(只用登录一次)
1. 点击 Chrome 右上角的 **拼图图标** 🧩
2. 找到 **Star Chart Search Enhancer**
3. 点击 **图钉** 📌 把它固定到工具栏
4. 点击插件图标,然后点 **"登录"**
5. 按提示完成公司账号登录
---
## 🚀 第五步:开始使用
### 打开星图页面
访问:
```
https://xingtu.cn/ad/creator/market
```
等待页面加载,你会看到页面上多了一排新按钮。
---
## 📝 主要功能
### 1️⃣ 导出达人数据(CSV)
当你需要导出达人的内容数据、效果预估、画像时使用:
- 先勾选你想导出数据的达人
- 点击 **"导出选中达人数据"**
- 等待下载完成
- 文件自动下载到电脑的"下载"文件夹
⚠️ **重要**:导出达人数据必须先勾选达人,因为它会额外请求达人详情页数据,不能默认导出全部。
- 内容数据:个人视频/星图视频的播放量中位数、完播率、互动率、发布作品、平均时长、平均点赞、平均评论、平均转发
- 效果预估:不同视频时长的预期CPM、预期CPE、预期播放量、爆文率
- 观众画像、粉丝画像、铁粉画像
- 秒思api数据:看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search
**只导出部分字段**
- 点击 **"选择字段"**
- 勾选你需要的字段,取消不需要的字段
- 点击 **"保存"**
- 再点击 **"导出选中达人数据"** 或 **"按星图ID导出"**
说明:达人ID、达人名称、导出状态、失败原因等基础字段会固定保留;你保存过一次后,下次导出会自动沿用这次勾选结果,不需要重新勾选。
### 2️⃣ 按ID导出达人数据
当你想批量查询特定达人ID的数据时使用:
- 点击 **"按星图ID导出"**
- 在弹出的对话框中输入达人ID(每行一个)
- 点击确认
- 等待下载完成
**适用场景**:已知一批达人星图ID,需要批量导出这些达人的CSV。
### 3️⃣ 提交批次
- 勾选你想提交的达人
- 点击 **"提交批次"**
- 输入批次名称(例如:`5月母婴达人第一批`
- 点击确认
### 4️⃣ 更新插件
- 点击浏览器右上角的插件图标
- 在 **"版本更新"** 区域查看是否有新版本
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
- 解压下载到的新版本 zip
- 打开 `chrome://extensions`
- 找到 `Star Chart Search Enhancer`
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
---
## 🔄 如何更新插件
收到新版本压缩包时:
1. 删掉桌面上的旧文件夹
2. 解压新的压缩包
3. 打开 `chrome://extensions`
4. 找到插件卡片,点击 **"重新加载"** 🔄
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择新解压出来的插件文件夹
---
## ❓ 常见问题
### Q: 页面没有多出按钮?
A: 先点击插件图标确认已登录,然后刷新页面(按 F5)
### Q: 提示登录失败?
A: 关闭弹窗再试一次,或检查网络连接
### Q: 导出没反应?
A: 检查浏览器的下载列表,文件可能已经下好了
### Q: 不小心把文件夹删了?
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
---
## ✅ 每日使用 checklist
- [ ] 打开 Chrome,确认插件图标在
- [ ] 点击图标,确认显示"已登录"
- [ ] 打开星图页面
- [ ] 正常使用导出/提交功能
---
## 🆘 还是不行?
把下面信息发给同事:
1. 你在哪一步卡住了
2. 页面截图
3. 扩展 ID(从 chrome://extensions 里看)
**记住正确的 ID`pkjopdibdnomhogjheclhnknmejccffg`**
+935
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -5,7 +5,12 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "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: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"
}, },
@@ -14,6 +19,8 @@
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"devDependencies": { "devDependencies": {
"@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",
+11
View File
@@ -0,0 +1,11 @@
{
"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"
}
Binary file not shown.
+15 -5
View File
@@ -1,12 +1,17 @@
import { cp, mkdir, rm } from "node:fs/promises"; import { cp, mkdir, rm, writeFile } 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 { build } from "tsup"; import { build } from "tsup";
import { createManifest } from "./manifest.mjs";
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, "..");
const distDir = path.join(projectRoot, "dist"); const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
const distDir = path.join(
projectRoot,
buildTarget === "release" ? "dist-release" : "dist"
);
await rm(distDir, { recursive: true, force: true }); await rm(distDir, { recursive: true, force: true });
await mkdir(path.join(distDir, "content"), { recursive: true }); await mkdir(path.join(distDir, "content"), { recursive: true });
@@ -68,11 +73,16 @@ await build({
} }
}); });
await cp( await writeFile(
path.join(projectRoot, "src/manifest.json"), path.join(distDir, "manifest.json"),
path.join(distDir, "manifest.json") `${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n`
); );
await cp( await cp(
path.join(projectRoot, "src/popup/index.html"), path.join(projectRoot, "src/popup/index.html"),
path.join(distDir, "popup/index.html") path.join(distDir, "popup/index.html")
); );
await cp(
path.join(projectRoot, "src/assets"),
path.join(distDir, "assets"),
{ recursive: true }
);
+71
View File
@@ -0,0 +1,71 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { resolveReleaseVersion } from "../release-version.mjs";
import { uploadReleaseAssets } from "./upload-release-assets.mjs";
const execFileAsync = promisify(execFile);
export async function runReleaseTagPipeline(env = process.env) {
const projectRoot = resolveProjectRoot();
const releaseVersion = resolveReleaseVersion(env);
const publicBaseUrl =
env.UPDATE_PUBLIC_BASE_URL ??
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${releaseVersion}`;
console.log(`release version: ${releaseVersion}`);
console.log("running build:release");
await runNpmScript("build:release", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion
});
console.log("running package-release");
await runNodeScript("scripts/package-release.mjs", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion
});
console.log("writing latest manifest");
await runNpmScript("write:latest", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion,
UPDATE_PUBLIC_BASE_URL: publicBaseUrl
});
console.log("uploading release assets to COS");
await uploadReleaseAssets({
env: {
...env,
EXTENSION_VERSION: releaseVersion
},
projectRoot,
releaseVersion
});
}
async function runNpmScript(scriptName, cwd, env) {
await execFileAsync("npm", ["run", scriptName], {
cwd,
env,
stdio: "inherit"
});
}
async function runNodeScript(scriptPath, cwd, env) {
await execFileAsync("node", [scriptPath], {
cwd,
env,
stdio: "inherit"
});
}
function resolveProjectRoot() {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await runReleaseTagPipeline();
}
+83
View File
@@ -0,0 +1,83 @@
import COS from "cos-nodejs-sdk-v5";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { buildReleaseUploadTargets } from "../release-assets.mjs";
export async function uploadReleaseAssets(options = {}) {
const env = options.env ?? process.env;
const projectRoot = options.projectRoot ?? resolveProjectRoot();
const releaseVersion = options.releaseVersion ?? env.EXTENSION_VERSION ?? env.DRONE_TAG;
if (!releaseVersion) {
throw new Error("release version is required for COS upload");
}
const cos = options.cosClient ?? createCosClient(env);
const targets =
options.targets ??
buildReleaseUploadTargets({
projectRoot,
releaseVersion
});
for (const target of targets) {
const body = await readFile(target.localPath);
await putObjectAsync(cos, {
Bucket: getRequiredEnv(env, "COS_BUCKET"),
Body: body,
ContentType: getContentType(target.cosKey),
Key: target.cosKey,
Region: getRequiredEnv(env, "COS_REGION")
});
}
}
async function putObjectAsync(client, params) {
return await new Promise((resolve, reject) => {
client.putObject(params, (error, data) => {
if (error) {
reject(error);
return;
}
resolve(data);
});
});
}
function createCosClient(env) {
return new COS({
SecretId: getRequiredEnv(env, "COS_SECRET_ID"),
SecretKey: getRequiredEnv(env, "COS_SECRET_KEY")
});
}
function getContentType(key) {
if (key.endsWith(".json")) {
return "application/json";
}
if (key.endsWith(".pdf")) {
return "application/pdf";
}
if (key.endsWith(".zip")) {
return "application/zip";
}
return "application/octet-stream";
}
function getRequiredEnv(env, name) {
const value = env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function resolveProjectRoot() {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
+79
View File
@@ -0,0 +1,79 @@
import { resolveReleaseVersion } from "./release-version.mjs";
const sharedIcons = {
16: "assets/icons/icon-16.png",
32: "assets/icons/icon-32.png",
48: "assets/icons/icon-48.png",
128: "assets/icons/icon-128.png"
};
const extensionKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB";
const sharedManifest = {
action: {
default_icon: {
16: sharedIcons[16],
32: sharedIcons[32]
},
default_popup: "popup/index.html"
},
background: {
service_worker: "background/index.js"
},
content_scripts: [
{
js: ["content/index.js"],
matches: [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*"
],
run_at: "document_start"
}
],
description: "Bootstraps the Xingtu creator market content script.",
icons: sharedIcons,
key: extensionKey,
manifest_version: 3,
name: "Star Chart Search Enhancer",
permissions: ["downloads", "identity", "storage"],
web_accessible_resources: [
{
matches: [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
],
resources: ["content/market-page-bridge.js"]
}
]
};
const hostPermissionsByTarget = {
development: [
"http://*/*",
"https://login-api.intelligrow.cn/*",
"http://127.0.0.1:4319/*",
"https://*/*"
],
release: [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*",
"https://*/*"
]
};
export function createManifest(options = {}) {
const target = options.target ?? "development";
const hostPermissions = hostPermissionsByTarget[target];
if (!hostPermissions) {
throw new Error(`Unsupported manifest target: ${target}`);
}
return {
...sharedManifest,
version: resolveReleaseVersion(),
host_permissions: hostPermissions
};
}
+24
View File
@@ -0,0 +1,24 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourceDir = path.join(projectRoot, "dist-release");
const releaseDir = path.join(projectRoot, "release");
const archivePath = path.join(
releaseDir,
"star-chart-search-enhancer-internal.zip"
);
await mkdir(releaseDir, { recursive: true });
await rm(archivePath, { force: true });
await execFileAsync("zip", ["-X", "-r", archivePath, "."], {
cwd: sourceDir
});
console.log(`Internal archive created at ${archivePath}`);
+25
View File
@@ -0,0 +1,25 @@
import path from "node:path";
export function buildReleaseUploadTargets({
projectRoot,
releaseVersion
}) {
const releaseDir = path.join(projectRoot, "release");
const releasePrefix = "star-chart-search-enhancer";
const releaseVersionPrefix = `${releasePrefix}/releases/${releaseVersion}`;
return [
{
cosKey: `${releasePrefix}/latest.json`,
localPath: path.join(releaseDir, "latest.json")
},
{
cosKey: `${releaseVersionPrefix}/star-chart-search-enhancer-internal.zip`,
localPath: path.join(releaseDir, "star-chart-search-enhancer-internal.zip")
},
{
cosKey: `${releaseVersionPrefix}/星图增强插件-超简单安装使用指南.pdf`,
localPath: path.join(releaseDir, "星图增强插件-超简单安装使用指南.pdf")
}
];
}
+30
View File
@@ -0,0 +1,30 @@
const RELEASE_VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
export function normalizeReleaseVersionTag(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().replace(/^v/i, "");
if (!RELEASE_VERSION_PATTERN.test(normalized)) {
return null;
}
return normalized;
}
export function resolveReleaseVersion(
env = process.env,
fallbackVersion = "0.2.0421.2"
) {
const candidates = [env.EXTENSION_VERSION, env.DRONE_TAG, fallbackVersion];
for (const candidate of candidates) {
const normalized = normalizeReleaseVersionTag(candidate);
if (normalized) {
return normalized;
}
}
throw new Error("unable to resolve a valid release version");
}
+15
View File
@@ -0,0 +1,15 @@
export function createLatestManifest(options) {
const publishedAt = options.publishedAt ?? new Date().toISOString().slice(0, 10);
return {
guideUrl: `${options.publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`,
latestVersion: options.latestVersion,
minSupportedVersion: options.minSupportedVersion,
publishedAt,
releaseNotes: [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
zipUrl: `${options.publicBaseUrl}/star-chart-search-enhancer-internal.zip`
};
}
+31
View File
@@ -0,0 +1,31 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createManifest } from "./manifest.mjs";
import { createLatestManifest } from "./write-latest-manifest-data.mjs";
import { resolveReleaseVersion } from "./release-version.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const releaseDir = path.join(projectRoot, "release");
const releaseManifest = createManifest({ target: "release" });
const latestVersion =
process.env.LATEST_VERSION ?? resolveReleaseVersion(process.env, releaseManifest.version);
const publicBaseUrl =
process.env.UPDATE_PUBLIC_BASE_URL ??
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${latestVersion}`;
const latestManifest = createLatestManifest({
latestVersion,
minSupportedVersion: releaseManifest.version,
publicBaseUrl
});
await mkdir(releaseDir, { recursive: true });
await writeFile(
path.join(releaseDir, "latest.json"),
`${JSON.stringify(latestManifest, null, 2)}\n`,
"utf8"
);
console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 822 B

+9
View File
@@ -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);
}, },
+3 -1
View File
@@ -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
}; };
} }
+55
View File
@@ -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;
+51 -8
View File
@@ -39,24 +39,36 @@ export async function bootContentScript(
return null; return null;
} }
installMarketPageBridge(currentDocument);
const authState = await readAuthState(sendAuthMessage); const authState = await readAuthState(sendAuthMessage);
if (!authState?.isAuthenticated) { if (!authState?.isAuthenticated) {
renderMarketAuthGate(currentDocument, currentWindow); await waitForBodyReady(currentDocument, currentWindow);
renderMarketAuthGate(
currentDocument,
currentWindow,
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
);
return { return {
ready: Promise.resolve() ready: Promise.resolve()
}; };
} }
installMarketPageBridge(currentDocument); await waitForBodyReady(currentDocument, currentWindow);
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
}); });
@@ -109,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 };
@@ -122,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;
@@ -144,14 +156,37 @@ function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
}; };
} }
function downloadCsv(document: Document, window: Window, csv: string): void { async function waitForBodyReady(document: Document, currentWindow: Window): Promise<void> {
if (document.body) {
return;
}
await new Promise<void>((resolve) => {
const handleReady = () => {
if (document.body) {
document.removeEventListener("DOMContentLoaded", handleReady);
resolve();
}
};
document.addEventListener("DOMContentLoaded", handleReady);
currentWindow.setTimeout(handleReady, 0);
});
}
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();
@@ -162,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;
}
+312
View File
@@ -0,0 +1,312 @@
import { escapeCsvCell } from "../../shared/csv";
import {
buildMarketCsvColumns,
listBackendMetricCsvHeaders,
listRateCsvHeaders,
type CsvColumn
} from "./csv-exporter";
import type {
AudienceProfileDistributionItem,
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityVideoKind,
BusinessAbilityVideoMetrics
} from "./audience-profile-types";
type AudienceProfileCsvColumn = {
header: string;
readValue: (row: AudienceProfileExportRow) => string;
};
export interface AudienceProfileCsvOptions {
selectedHeaders?: string[];
}
export type AudienceProfileCsvFieldGroup = {
headers: string[];
label: string;
};
const PROFILE_LAYOUTS: Array<{
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}> = [
{ includeGender: true, kind: "audience", label: "观众画像" },
{ includeGender: true, kind: "fans", label: "粉丝画像" },
{ includeGender: false, kind: "longtimeFans", label: "铁粉画像" }
];
const GENDER_LABELS = ["男性", "女性"];
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
const CITY_TIER_LABELS = [
"一线城市",
"二线城市",
"三线城市",
"四线城市",
"五线城市"
];
const CROWD_LABELS = [
"精致妈妈",
"都市银发",
"新锐白领",
"资深中产",
"都市蓝领",
"Z世代",
"小镇中老年",
"小镇青年"
];
const BUSINESS_VIDEO_LAYOUTS: Array<{
key: BusinessAbilityVideoKind;
label: string;
}> = [
{ key: "personalVideo", label: "个人视频" },
{ key: "xingtuVideo", label: "星图视频" }
];
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityVideoMetrics;
label: string;
}> = [
{ key: "medianPlay", label: "播放量中位数" },
{ key: "finishRate", label: "完播率" },
{ key: "interactionRate", label: "互动率" },
{ key: "publishedItems", label: "发布作品" },
{ key: "averageDuration", label: "平均时长" },
{ key: "averageLike", label: "平均点赞" },
{ key: "averageComment", label: "平均评论" },
{ key: "averageShare", label: "平均转发" }
];
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
key: BusinessAbilityDurationKind;
label: string;
}> = [
{ key: "oneToTwenty", label: "1-20s视频" },
{ key: "twentyToSixty", label: "20-60s视频" },
{ key: "overSixty", label: "60s以上视频" }
];
const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityEstimateMetrics;
label: string;
}> = [
{ key: "expectedCpm", label: "预期CPM" },
{ key: "expectedCpe", label: "预期CPE" },
{ key: "expectedPlay", label: "预期播放量" },
{ key: "hotRate", label: "爆文率" }
];
export function buildAudienceProfileCsv(
rows: AudienceProfileExportRow[],
options: AudienceProfileCsvOptions = {}
): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = filterAudienceProfileCsvColumns([
...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
], options.selectedHeaders);
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = rows.map((row) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
);
return [headerLine, ...rowLines].join("\n");
}
export function listAudienceProfileCsvHeaders(
rows: AudienceProfileExportRow[] = []
): string[] {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
return [
...marketColumns.map((column) => column.header),
...buildBusinessAbilityColumns().map((column) => column.header),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map(
(column) => column.header
)
];
}
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
return [
{
headers: listRateCsvHeaders(),
label: "看后搜率"
},
{
headers: listBackendMetricCsvHeaders(),
label: "秒思api数据"
},
{
headers: buildBusinessVideoColumns().map((column) => column.header),
label: "内容数据"
},
{
headers: buildBusinessEstimateColumns().map((column) => column.header),
label: "效果预估"
},
...PROFILE_LAYOUTS.map((layout) => ({
headers: buildProfileColumns(layout).map((column) => column.header),
label: layout.label
}))
];
}
function filterAudienceProfileCsvColumns(
columns: AudienceProfileCsvColumn[],
selectedHeaders: string[] | undefined
): AudienceProfileCsvColumn[] {
if (!selectedHeaders) {
return columns;
}
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
const selectedHeaderSet = new Set(selectedHeaders);
return columns.filter(
(column) =>
!selectableHeaderSet.has(column.header) ||
selectedHeaderSet.has(column.header)
);
}
function listAudienceProfileSelectableHeaders(): string[] {
return listAudienceProfileSelectableFieldGroups().flatMap(
(group) => group.headers
);
}
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
}
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
}))
)
];
}
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessEstimateValue(row, durationLayout.key, metricLayout.key)
}))
)
];
}
function readBusinessVideoValue(
row: AudienceProfileExportRow,
videoKey: BusinessAbilityVideoKind,
metricKey: keyof BusinessAbilityVideoMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
}
function readBusinessEstimateValue(
row: AudienceProfileExportRow,
durationKey: BusinessAbilityDurationKind,
metricKey: keyof BusinessAbilityEstimateMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.estimates[durationKey]?.[metricKey] ?? "";
}
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
return {
header: column.header,
readValue: (row) => column.readValue(row.record)
};
}
function buildProfileColumns(layout: {
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}): AudienceProfileCsvColumn[] {
const columns: AudienceProfileCsvColumn[] = [];
if (layout.includeGender) {
columns.push(
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"gender",
GENDER_LABELS
)
);
}
columns.push(
...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS),
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"cityTier",
CITY_TIER_LABELS
),
...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS)
);
return columns;
}
function buildFixedDistributionColumns(
prefix: string,
kind: AudienceProfileKind,
key: "age" | "cityTier" | "crowd" | "gender",
labels: string[]
): AudienceProfileCsvColumn[] {
return labels.map((label) => ({
header: `${prefix}-${label}占比`,
readValue: (row) => readDistributionValue(row.profiles[kind], key, label)
}));
}
function readDistributionValue(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender",
label: string
): string {
if (profile.status !== "success") {
return "";
}
return (
readProfileDistributionItems(profile, key).find(
(candidate) => candidate.label === label
)?.value ?? "0%"
);
}
function readProfileDistributionItems(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender"
): AudienceProfileDistributionItem[] {
return profile.status === "success" ? profile[key] ?? [] : [];
}
@@ -0,0 +1,295 @@
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
export function promptForAudienceProfileFields(
document: Document,
groups: AudienceProfileCsvFieldGroup[],
selectedHeaders: string[]
): Promise<string[] | null> {
return new Promise((resolve) => {
const selectableHeaders = groups.flatMap((group) => group.headers);
const selectedHeaderSet = new Set(
selectedHeaders.filter((header) => selectableHeaders.includes(header))
);
if (selectedHeaderSet.size === 0) {
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
}
const overlay = document.createElement("div");
overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
applyDialogStyles(dialog);
const title = document.createElement("h2");
applyTitleStyles(title);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
applyHintStyles(hint);
const toolbar = document.createElement("div");
applyToolbarStyles(toolbar);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applySecondaryButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applySecondaryButtonStyles(resetButton);
toolbar.append(selectAllButton, resetButton);
const groupContainer = document.createElement("div");
applyGroupContainerStyles(groupContainer);
const fieldInputs: HTMLInputElement[] = [];
groups.forEach((group) => {
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
const groupFieldInputs = group.headers.map((header) => {
const fieldLabel = document.createElement("label");
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
input.checked = selectedHeaderSet.has(header);
const text = document.createElement("span");
text.textContent = header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
fieldInputs.push(input);
return input;
});
const syncGroupInput = () => {
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === groupFieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
};
groupInput.addEventListener("change", () => {
groupFieldInputs.forEach((input) => {
input.checked = groupInput.checked;
});
syncTitle();
});
groupFieldInputs.forEach((input) => {
input.addEventListener("change", () => {
syncGroupInput();
syncTitle();
});
});
syncGroupInput();
groupSection.append(groupHeader, fieldList);
groupContainer.append(groupSection);
});
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, toolbar, groupContainer, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function syncTitle() {
const checkedCount = fieldInputs.filter((input) => input.checked).length;
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
}
function close(value: string[] | null) {
overlay.remove();
resolve(value);
}
selectAllButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
resetButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
const nextHeaders = fieldInputs
.filter((input) => input.checked)
.map((input) => input.value);
close(nextHeaders);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
syncTitle();
});
}
function syncAllGroupInputs(dialog: HTMLElement): void {
dialog
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]')
.forEach((section) => {
const groupInput = section.querySelector(":scope > label > input");
const fieldInputs = Array.from(
section.querySelectorAll(":scope > div input")
) as HTMLInputElement[];
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) {
return;
}
const checkedCount = fieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === fieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length;
});
}
function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "2147483647";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(15, 23, 42, 0.38)";
}
function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "680px";
dialog.style.maxWidth = "calc(100vw - 32px)";
dialog.style.maxHeight = "calc(100vh - 48px)";
dialog.style.display = "flex";
dialog.style.flexDirection = "column";
dialog.style.background = "#ffffff";
dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
dialog.style.padding = "20px";
dialog.style.boxSizing = "border-box";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px";
title.style.fontSize = "18px";
title.style.fontWeight = "700";
title.style.color = "#1f2329";
}
function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px";
hint.style.fontSize = "13px";
hint.style.lineHeight = "20px";
hint.style.color = "#64748b";
}
function applyToolbarStyles(toolbar: HTMLElement): void {
toolbar.style.display = "flex";
toolbar.style.gap = "8px";
toolbar.style.marginBottom = "12px";
}
function applyGroupContainerStyles(container: HTMLElement): void {
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "10px";
container.style.overflow = "auto";
container.style.paddingRight = "4px";
}
function applyGroupSectionStyles(section: HTMLElement): void {
section.style.border = "1px solid #e5e7eb";
section.style.borderRadius = "8px";
section.style.padding = "10px";
}
function applyGroupHeaderStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "8px";
label.style.fontWeight = "700";
label.style.color = "#1f2329";
label.style.marginBottom = "8px";
}
function applyFieldListStyles(list: HTMLElement): void {
list.style.display = "grid";
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
list.style.gap = "8px";
}
function applyFieldLabelStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
label.style.fontSize = "13px";
label.style.lineHeight = "18px";
label.style.color = "#374151";
}
function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.columnGap = "8px";
actions.style.marginTop = "14px";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #d0d7de";
button.style.borderRadius = "8px";
button.style.background = "#ffffff";
button.style.color = "#1f2329";
button.style.fontWeight = "600";
}
@@ -0,0 +1,87 @@
import type { MarketRecord } from "./types";
export type AudienceProfileKind =
| "audience"
| "fans"
| "longtimeFans";
export interface AudienceProfileDistributionItem {
label: string;
value: string;
}
export interface AudienceProfileSuccess {
age?: AudienceProfileDistributionItem[];
cityTier?: AudienceProfileDistributionItem[];
cityTop?: AudienceProfileDistributionItem[];
crowd?: AudienceProfileDistributionItem[];
gender?: AudienceProfileDistributionItem[];
interest?: AudienceProfileDistributionItem[];
province?: AudienceProfileDistributionItem[];
status: "success";
}
export interface AudienceProfileFailure {
failureReason?: string;
status: "failed";
}
export interface AudienceProfileSet {
audience: AudienceProfileResult;
fans: AudienceProfileResult;
longtimeFans: AudienceProfileResult;
}
export type AudienceProfileResult =
| AudienceProfileSuccess
| AudienceProfileFailure;
export interface AudienceProfileExportRow {
businessAbility?: BusinessAbilityResult;
profiles: AudienceProfileSet;
record: MarketRecord;
}
export type BusinessAbilityVideoKind =
| "personalVideo"
| "xingtuVideo";
export interface BusinessAbilityVideoMetrics {
averageComment: string;
averageDuration: string;
averageLike: string;
averageShare: string;
finishRate: string;
interactionRate: string;
medianPlay: string;
publishedItems: string;
}
export type BusinessAbilityDurationKind =
| "oneToTwenty"
| "twentyToSixty"
| "overSixty";
export interface BusinessAbilityEstimateMetrics {
expectedCpe: string;
expectedCpm: string;
expectedPlay: string;
hotRate: string;
}
export interface BusinessAbilitySuccess {
estimates: Partial<
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
>;
status: "success";
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
}
export interface BusinessAbilityFailure {
failureReason?: string;
status: "failed";
}
export type BusinessAbilityResult =
| BusinessAbilitySuccess
| BusinessAbilityFailure;
+7 -2
View File
@@ -1,6 +1,7 @@
export function renderMarketAuthGate( 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"]')
+122
View File
@@ -0,0 +1,122 @@
import type { MarketRecord } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface AuthorBaseClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
export function createAuthorBaseClient(options: AuthorBaseClientOptions = {}) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorBaseInfo(authorId: string): Promise<MarketRecord> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAuthorBaseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
if (!response.ok) {
return buildFailedRecord(authorId, "request-failed");
}
return mapAuthorBaseInfoResponse(authorId, await response.json());
} catch (error) {
return buildFailedRecord(
authorId,
error instanceof Error && error.name === "AbortError"
? "timeout"
: "request-failed"
);
} finally {
clearTimeout(timeoutId);
}
}
};
}
export function buildAuthorBaseInfoUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL("/gw/api/author/get_author_base_info", baseUrl);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("platform_source", "1");
url.searchParams.set("platform_channel", "1");
url.searchParams.set("recommend", "true");
url.searchParams.set("need_sec_uid", "true");
url.searchParams.set("need_linkage_info", "true");
return url.toString();
}
export function mapAuthorBaseInfoResponse(
authorId: string,
payload: unknown
): MarketRecord {
if (!isRecord(payload)) {
return buildFailedRecord(authorId, "bad-response");
}
const authorName = readString(payload.nick_name);
if (!authorName) {
return buildFailedRecord(authorId, "missing-rate");
}
return {
authorId,
authorName,
status: "success"
};
}
function buildFailedRecord(
authorId: string,
failureReason: MarketRecord["failureReason"]
): MarketRecord {
return {
authorId,
authorName: "",
failureReason,
status: "failed"
};
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://xingtu.cn";
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+130
View File
@@ -0,0 +1,130 @@
export function promptForAuthorIds(document: Document): Promise<string | null> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.dataset.authorIdDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
applyDialogStyles(dialog);
const title = document.createElement("h2");
title.textContent = "按星图ID导出";
applyTitleStyles(title);
const textarea = document.createElement("textarea");
textarea.dataset.authorIdDialogInput = "textarea";
textarea.placeholder = "每行一个星图ID,也支持逗号、空格分隔";
applyTextareaStyles(textarea);
const hint = document.createElement("p");
hint.textContent = "粘贴客户提供的达人星图ID,确认后将批量导出达人数据。";
applyHintStyles(hint);
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.textContent = "开始导出";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, textarea, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
const close = (value: string | null) => {
overlay.remove();
resolve(value);
};
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => close(textarea.value));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
textarea.focus();
});
}
function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "2147483647";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(15, 23, 42, 0.38)";
}
function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "520px";
dialog.style.maxWidth = "calc(100vw - 32px)";
dialog.style.background = "#ffffff";
dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
dialog.style.padding = "20px";
dialog.style.boxSizing = "border-box";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px";
title.style.fontSize = "18px";
title.style.fontWeight = "700";
title.style.color = "#1f2329";
}
function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px";
hint.style.fontSize = "13px";
hint.style.lineHeight = "20px";
hint.style.color = "#64748b";
}
function applyTextareaStyles(textarea: HTMLTextAreaElement): void {
textarea.style.width = "100%";
textarea.style.height = "220px";
textarea.style.resize = "vertical";
textarea.style.border = "1px solid #d0d7de";
textarea.style.borderRadius = "6px";
textarea.style.padding = "10px";
textarea.style.boxSizing = "border-box";
textarea.style.fontSize = "13px";
textarea.style.lineHeight = "20px";
textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
textarea.style.color = "#1f2329";
}
function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.columnGap = "8px";
actions.style.marginTop = "14px";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #d0d7de";
button.style.borderRadius = "8px";
button.style.background = "#ffffff";
button.style.color = "#1f2329";
button.style.fontWeight = "600";
}
+39
View File
@@ -0,0 +1,39 @@
export interface ParsedAuthorIds {
duplicates: string[];
invalidTokens: string[];
ids: string[];
}
const AUTHOR_ID_PATTERN = /^\d{16,20}$/;
export function parseAuthorIds(input: string): ParsedAuthorIds {
const ids: string[] = [];
const duplicates: string[] = [];
const invalidTokens: string[] = [];
const seen = new Set<string>();
input
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter(Boolean)
.forEach((token) => {
if (!/^\d+$/.test(token) || !AUTHOR_ID_PATTERN.test(token)) {
invalidTokens.push(token);
return;
}
if (seen.has(token)) {
duplicates.push(token);
return;
}
seen.add(token);
ids.push(token);
});
return {
duplicates,
ids,
invalidTokens
};
}
+245
View File
@@ -0,0 +1,245 @@
const DIALOG_STYLE_ID = "sces-batch-name-dialog-style";
const activeDialogs = new WeakMap<
Document,
{
input: HTMLInputElement;
promise: Promise<string | null>;
}
>();
export function promptForBatchName(document: Document): Promise<string | null> {
const existingDialog = activeDialogs.get(document);
if (existingDialog) {
existingDialog.input.focus();
existingDialog.input.select();
return existingDialog.promise;
}
ensureDialogStyles(document);
const dialogRoot = document.createElement("div");
dialogRoot.dataset.pluginBatchNameDialog = "root";
dialogRoot.setAttribute("role", "dialog");
dialogRoot.setAttribute("aria-modal", "true");
dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title");
applyOverlayStyles(dialogRoot);
const dialogPanel = document.createElement("div");
applyPanelStyles(dialogPanel);
const title = document.createElement("h2");
title.id = "sces-batch-name-title";
title.textContent = "提交批次";
applyTitleStyles(title);
const description = document.createElement("p");
description.textContent = "请输入批次名称,便于后续在系统中识别和追踪。";
applyDescriptionStyles(description);
const input = document.createElement("input");
input.type = "text";
input.dataset.pluginBatchNameInput = "input";
input.placeholder = "例如:618达人筛选第一批";
input.maxLength = 60;
applyInputStyles(input);
const errorText = document.createElement("p");
errorText.dataset.pluginBatchNameError = "text";
applyErrorStyles(errorText);
const buttonRow = document.createElement("div");
applyButtonRowStyles(buttonRow);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.dataset.pluginBatchNameCancel = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.pluginBatchNameConfirm = "button";
confirmButton.textContent = "确认提交";
applyPrimaryButtonStyles(confirmButton);
buttonRow.append(cancelButton, confirmButton);
dialogPanel.append(title, description, input, errorText, buttonRow);
dialogRoot.appendChild(dialogPanel);
document.body.appendChild(dialogRoot);
const dialogPromise = new Promise<string | null>((resolve) => {
const closeDialog = (value: string | null) => {
activeDialogs.delete(document);
dialogRoot.remove();
document.removeEventListener("keydown", handleDocumentKeydown, true);
resolve(value);
};
const submitValue = () => {
const value = input.value.trim();
if (!value) {
errorText.textContent = "请输入批次名称";
input.setAttribute("aria-invalid", "true");
input.focus();
return;
}
closeDialog(value);
};
const handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeDialog(null);
return;
}
if (event.key === "Enter") {
event.preventDefault();
submitValue();
}
};
input.addEventListener("input", () => {
if (!input.value.trim()) {
return;
}
errorText.textContent = "";
input.removeAttribute("aria-invalid");
});
cancelButton.addEventListener("click", () => {
closeDialog(null);
});
confirmButton.addEventListener("click", () => {
submitValue();
});
dialogRoot.addEventListener("click", (event) => {
if (event.target === dialogRoot) {
closeDialog(null);
}
});
document.addEventListener("keydown", handleDocumentKeydown, true);
});
activeDialogs.set(document, {
input,
promise: dialogPromise
});
input.focus();
return dialogPromise;
}
function ensureDialogStyles(document: Document): void {
if (document.getElementById(DIALOG_STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = DIALOG_STYLE_ID;
style.textContent = `
[data-plugin-batch-name-dialog="root"] {
animation: sces-batch-name-fade-in 0.16s ease;
}
@keyframes sces-batch-name-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
document.head.appendChild(style);
}
function applyOverlayStyles(root: HTMLElement): void {
root.style.position = "fixed";
root.style.inset = "0";
root.style.background = "rgba(15, 23, 42, 0.38)";
root.style.display = "flex";
root.style.alignItems = "center";
root.style.justifyContent = "center";
root.style.padding = "24px";
root.style.zIndex = "2147483647";
}
function applyPanelStyles(panel: HTMLElement): void {
panel.style.width = "min(420px, calc(100vw - 32px))";
panel.style.background = "#fffaf9";
panel.style.border = "1px solid rgba(127, 29, 45, 0.14)";
panel.style.borderRadius = "18px";
panel.style.boxShadow = "0 28px 70px rgba(15, 23, 42, 0.22)";
panel.style.padding = "24px";
panel.style.boxSizing = "border-box";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0";
title.style.color = "#4c0519";
title.style.fontSize = "20px";
title.style.fontWeight = "700";
title.style.lineHeight = "28px";
}
function applyDescriptionStyles(description: HTMLElement): void {
description.style.margin = "10px 0 0";
description.style.color = "#64748b";
description.style.fontSize = "13px";
description.style.lineHeight = "20px";
}
function applyInputStyles(input: HTMLInputElement): void {
input.style.width = "100%";
input.style.height = "42px";
input.style.marginTop = "18px";
input.style.padding = "0 14px";
input.style.boxSizing = "border-box";
input.style.border = "1px solid #d8c1c6";
input.style.borderRadius = "12px";
input.style.background = "#ffffff";
input.style.color = "#1f2937";
input.style.fontSize = "14px";
input.style.outline = "none";
}
function applyErrorStyles(errorText: HTMLElement): void {
errorText.style.minHeight = "20px";
errorText.style.margin = "8px 0 0";
errorText.style.color = "#b91c1c";
errorText.style.fontSize = "12px";
errorText.style.lineHeight = "18px";
}
function applyButtonRowStyles(buttonRow: HTMLElement): void {
buttonRow.style.display = "flex";
buttonRow.style.justifyContent = "flex-end";
buttonRow.style.gap = "10px";
buttonRow.style.marginTop = "18px";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "36px";
button.style.padding = "0 16px";
button.style.border = "1px solid #d7dde6";
button.style.borderRadius = "10px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.fontWeight = "600";
button.style.cursor = "pointer";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "36px";
button.style.padding = "0 16px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "10px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
button.style.cursor = "pointer";
}
+3 -3
View File
@@ -5,8 +5,8 @@ export interface BatchPayload {
authors: Array<{ authors: Array<{
authorId: string; authorId: string;
authorName: string; authorName: string;
authorUid?: string;
}>; }>;
batchId: string;
batchName: string; batchName: string;
createdAt: string; createdAt: string;
creatorName: string; creatorName: string;
@@ -38,9 +38,9 @@ export function createBatchPayload(options: {
return { return {
authors: options.records.map((record) => ({ authors: options.records.map((record) => ({
authorId: record.authorId, authorId: record.authorId,
authorName: record.authorName authorName: record.authorName,
...(record.coreUserId ? { authorUid: record.coreUserId } : {})
})), })),
batchId: `${logtoUserId}-${options.createdAt}`,
batchName, batchName,
createdAt: options.createdAt, createdAt: options.createdAt,
creatorName: creatorName:
@@ -0,0 +1,289 @@
import type { MarketRecord } from "./types";
import type {
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityResult,
BusinessAbilitySuccess,
BusinessAbilityVideoMetrics
} from "./audience-profile-types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BusinessAbilityClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
const VIDEO_TYPES = {
personalVideo: 1,
xingtuVideo: 2
} as const;
export function createBusinessAbilityClient(
options: BusinessAbilityClientOptions = {}
) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
const personalVideo = await loadJson(
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
);
const xingtuVideo = await loadJson(
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
);
const estimates = await loadJson(
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
);
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
return {
failureReason:
personalVideo.failureReason ??
xingtuVideo.failureReason ??
estimates.failureReason,
status: "failed"
};
}
return {
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
status: "success",
videos: {
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
}
} satisfies BusinessAbilitySuccess;
}
};
async function loadJson(url: string): Promise<
| { ok: true; payload: unknown }
| { failureReason: string; ok: false }
> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
credentials: "include",
method: "GET",
signal: controller.signal
});
if (!response.ok) {
return { failureReason: "request-failed", ok: false };
}
return { ok: true, payload: await response.json() };
} catch (error) {
return {
failureReason:
error instanceof Error && error.name === "AbortError"
? "timeout"
: "request-failed",
ok: false
};
} finally {
clearTimeout(timeoutId);
}
}
}
export function buildBusinessAbilityVideoUrl(
authorId: string,
baseUrl: string,
videoType: number
): string {
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("platform_source", "1");
url.searchParams.set("platform_channel", "1");
url.searchParams.set("type", String(videoType));
url.searchParams.set("flow_type", "0");
url.searchParams.set("only_assign", "true");
url.searchParams.set("range", "2");
return url.toString();
}
export function buildBusinessAbilityEstimateUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_spread_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
return url.toString();
}
export function mapBusinessAbilityVideoResponse(
payload: unknown
): BusinessAbilityVideoMetrics {
const data = getPayloadData(payload);
return {
averageComment: formatWan(readNumber(data?.comment_avg)),
averageDuration: formatDuration(readNumber(data?.avg_duration)),
averageLike: formatWan(readNumber(data?.like_avg)),
averageShare: formatWan(readNumber(data?.share_avg)),
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
interactionRate: formatBasisPointRate(
readNestedNumber(data, "interact_rate", "value")
),
medianPlay: formatWan(readNumber(data?.play_mid)),
publishedItems: formatPublishedItems(readNumber(data?.item_num))
};
}
export function mapBusinessAbilityEstimateResponse(
payload: unknown
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
const data = getPayloadData(payload);
const expectedPlay = formatWan(readNumber(data?.vv));
const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate));
return {
oneToTwenty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1),
expectedPlay,
hotRate
},
overSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1),
expectedPlay,
hotRate
},
twentyToSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1),
expectedPlay,
hotRate
}
};
}
function formatPublishedItems(value: number | null): string {
if (value === null) {
return "";
}
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
}
function formatDuration(value: number | null): string {
if (value === null) {
return "";
}
return `${formatDecimal(value / 100, 0)}s`;
}
function formatBasisPointRate(value: number | null): string {
if (value === null) {
return "";
}
return `${formatDecimal(value / 100, 1)}%`;
}
function formatDecimalRate(value: number | null): string {
if (value === null) {
return "缺失";
}
return `${formatDecimal(value * 100, 0)}%`;
}
function formatWan(value: number | null): string {
if (value === null) {
return "";
}
if (Math.abs(value) >= 10000) {
return `${formatDecimal(value / 10000, 1)}w`;
}
return formatDecimal(value, 0);
}
function formatDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
const fixed = value.toFixed(digits);
return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function formatFixedDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
return value.toFixed(digits);
}
function readNestedNumber(
data: Record<string, unknown> | null,
objectKey: string,
valueKey: string
): number | null {
const objectValue = data?.[objectKey];
if (!isRecord(objectValue)) {
return null;
}
return readNumber(objectValue[valueKey]);
}
function readNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
return null;
}
function getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
return isRecord(payload.data) ? payload.data : payload;
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://xingtu.cn";
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+23 -11
View File
@@ -2,7 +2,7 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import { escapeCsvCell } from "../../shared/csv"; import { 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(["代表视频"]);
+205 -43
View File
@@ -2,6 +2,7 @@ import {
normalizeFractionRateDisplay, normalizeFractionRateDisplay,
normalizeRateDisplay normalizeRateDisplay
} from "../../shared/rate-normalizer"; } from "../../shared/rate-normalizer";
import { mapMarketListRow } from "./market-list-row";
import type { import type {
AfterSearchRates, AfterSearchRates,
BackendMetrics, BackendMetrics,
@@ -11,6 +12,7 @@ import type {
} from "./types"; } from "./types";
const BACKEND_COLUMN_KEY = "backendMetrics"; const BACKEND_COLUMN_KEY = "backendMetrics";
const SELECTION_COLUMN_KEY = "selection";
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate"; const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate"; const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
const ACTION_HEADER_TEXT = "操作"; const ACTION_HEADER_TEXT = "操作";
@@ -68,6 +70,7 @@ type RowOrderTarget = {
type MarketDataRow = { type MarketDataRow = {
authorId: string; authorId: string;
authorName: string; authorName: string;
coreUserId?: string;
exportFields?: Record<string, string>; exportFields?: Record<string, string>;
hasDirectRatesSource: boolean; hasDirectRatesSource: boolean;
location?: string; location?: string;
@@ -86,12 +89,14 @@ export interface MarketRowDom {
price21To60s?: string; price21To60s?: string;
rates?: AfterSearchRates; rates?: AfterSearchRates;
row: HTMLElement; row: HTMLElement;
selectionCheckbox: HTMLInputElement;
singleCell: HTMLElement; singleCell: HTMLElement;
visibilityTargets: HTMLElement[]; visibilityTargets: HTMLElement[];
orderTargets: RowOrderTarget[]; orderTargets: RowOrderTarget[];
} }
export interface MarketTableDom { export interface MarketTableDom {
headerSelectionCheckbox: HTMLInputElement | null;
rows: MarketRowDom[]; rows: MarketRowDom[];
} }
@@ -255,6 +260,34 @@ export function syncPluginSortHeaders(
}); });
} }
export function syncMarketSelectionState(
table: MarketTableDom,
selectedAuthorIds: Set<string>
): void {
table.rows.forEach((rowDom) => {
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId);
});
if (!table.headerSelectionCheckbox) {
return;
}
const visibleRows = table.rows.filter((rowDom) =>
rowDom.visibilityTargets.some((target) => !target.hidden)
);
const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows;
const selectedCount = scopedRows.filter((rowDom) =>
selectedAuthorIds.has(rowDom.authorId)
).length;
table.headerSelectionCheckbox.indeterminate =
selectedCount > 0 && selectedCount < scopedRows.length;
table.headerSelectionCheckbox.checked =
scopedRows.length > 0 && selectedCount === scopedRows.length;
table.headerSelectionCheckbox.disabled = scopedRows.length === 0;
}
function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
const header = root.querySelector("[data-market-header]") as HTMLElement | null; const header = root.querySelector("[data-market-header]") as HTMLElement | null;
const body = root.querySelector("[data-market-body]") as HTMLElement | null; const body = root.querySelector("[data-market-body]") as HTMLElement | null;
@@ -263,7 +296,9 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
return null; return null;
} }
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率"); const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
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);
@@ -273,14 +308,18 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map( const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
(rowElement) => { (rowElement) => {
const row = rowElement as HTMLElement; const row = rowElement as HTMLElement;
const selectionCell = ensureSyntheticRowCell(row, SELECTION_COLUMN_KEY);
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY); const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY); const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
const backendMetricsCells = Object.fromEntries( const backendMetricsCells = Object.fromEntries(
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)]) BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
) as Record<BackendMetricField, HTMLElement>; ) as Record<BackendMetricField, HTMLElement>;
const authorId = row.dataset.authorId ?? "";
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
return { return {
authorId: row.dataset.authorId ?? "", authorId,
authorName: authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"", "",
@@ -303,6 +342,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
?.textContent?.trim() ?? "", ?.textContent?.trim() ?? "",
rates: undefined, rates: undefined,
row, row,
selectionCheckbox,
singleCell, singleCell,
visibilityTargets: [row] visibilityTargets: [row]
} satisfies MarketRowDom; } satisfies MarketRowDom;
@@ -310,6 +350,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
); );
return { return {
headerSelectionCheckbox,
rows rows
}; };
} }
@@ -392,7 +433,7 @@ function readDivGridAuthorIds(root: ParentNode): string[] | null {
) ?? null ) ?? null
: null; : null;
const authorColumn = authorSection const authorColumn = authorSection
? getDirectContentColumns(authorSection)[0] ?? null ? getNativeAuthorColumn(authorSection)
: null; : null;
if (!authorColumn) { if (!authorColumn) {
return null; return null;
@@ -435,12 +476,16 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
bodySection, bodySection,
getDirectChildIndex(headerSection, authorHeader) getDirectChildIndex(headerSection, authorHeader)
); );
const authorHeaderSection = getIndexedChild(
headerSection,
getDirectChildIndex(headerSection, authorHeader)
);
const rightSection = getIndexedChild( const rightSection = getIndexedChild(
bodySection, bodySection,
getDirectChildIndex(headerSection, actionHeader) getDirectChildIndex(headerSection, actionHeader)
); );
if (!authorSection || !rightSection) { if (!authorSection || !authorHeaderSection || !rightSection) {
return null; return null;
} }
const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection; const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection;
@@ -454,7 +499,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
type: "body" type: "body"
}); });
const authorColumn = getDirectContentColumns(authorSection)[0] ?? null; const authorColumn = getNativeAuthorColumn(authorSection);
const actionColumn = getActionColumn(rightSection); const actionColumn = getActionColumn(rightSection);
if (!authorColumn || !actionColumn) { if (!authorColumn || !actionColumn) {
@@ -462,6 +507,19 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
} }
const rowCount = getDirectContentCells(authorColumn).length; const rowCount = getDirectContentCells(authorColumn).length;
const selectionHeaderCell = ensureDivHeaderCell(
authorHeaderSection,
authorHeader,
SELECTION_COLUMN_KEY,
""
);
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeaderCell);
const selectionColumn = ensureDivBodyColumn(
authorSection,
authorColumn,
SELECTION_COLUMN_KEY,
rowCount
);
const headerTemplateCell = const headerTemplateCell =
getDirectHeaderCells(middleHeaderSection).at(-1) ?? getDirectHeaderCells(middleHeaderSection).at(-1) ??
findPreviousHeaderCell(actionHeader) ?? findPreviousHeaderCell(actionHeader) ??
@@ -474,7 +532,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
pluginHeaderSection, pluginHeaderSection,
headerTemplateCell, headerTemplateCell,
SINGLE_COLUMN_KEY, SINGLE_COLUMN_KEY,
"单视频看后搜率" "单视频看后搜率"
); );
ensureDivHeaderCell( ensureDivHeaderCell(
pluginHeaderSection, pluginHeaderSection,
@@ -510,6 +568,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
) as Record<BackendMetricField, HTMLElement>; ) as Record<BackendMetricField, HTMLElement>;
syncContainerWidth(pluginHeaderSection); syncContainerWidth(pluginHeaderSection);
syncContainerWidth(pluginBodySection); syncContainerWidth(pluginBodySection);
syncContainerWidth(authorHeaderSection);
syncContainerWidth(authorSection);
ensureVisibleHorizontalScroll(headerSection); ensureVisibleHorizontalScroll(headerSection);
ensureVisibleHorizontalScroll(bodySection); ensureVisibleHorizontalScroll(bodySection);
ensureScrollHint(root, headerSection); ensureScrollHint(root, headerSection);
@@ -525,6 +585,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
: [] : []
); );
const authorCells = getDirectContentCells(authorColumn); const authorCells = getDirectContentCells(authorColumn);
const selectionCells = getDirectContentCells(selectionColumn);
const singleCells = getDirectContentCells(singleColumn); const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn); const personalCells = getDirectContentCells(personalColumn);
const backendMetricCellsByField = Object.fromEntries( const backendMetricCellsByField = Object.fromEntries(
@@ -539,6 +600,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)]; const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
const rows = authorCells.flatMap((authorCell, index) => { const rows = authorCells.flatMap((authorCell, index) => {
const selectionCell = selectionCells[index] ?? null;
const singleCell = singleCells[index] ?? null; const singleCell = singleCells[index] ?? null;
const personalCell = personalCells[index] ?? null; const personalCell = personalCells[index] ?? null;
const backendMetricsCells = Object.fromEntries( const backendMetricsCells = Object.fromEntries(
@@ -548,12 +610,14 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
]) ])
) as Record<BackendMetricField, HTMLElement | null>; ) as Record<BackendMetricField, HTMLElement | null>;
if ( if (
!selectionCell ||
!singleCell || !singleCell ||
!personalCell || !personalCell ||
Object.values(backendMetricsCells).some((cell) => cell === null) Object.values(backendMetricsCells).some((cell) => cell === null)
) { ) {
return []; return [];
} }
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
const alignedRowCells = allBodyColumns.map( const alignedRowCells = allBodyColumns.map(
(column) => getDirectContentCells(column)[index] ?? null (column) => getDirectContentCells(column)[index] ?? null
@@ -584,6 +648,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
readDivGridPriceDisplay(priceCells[index]?.textContent), readDivGridPriceDisplay(priceCells[index]?.textContent),
fallbackMarketRow?.price21To60s fallbackMarketRow?.price21To60s
); );
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
return [ return [
{ {
@@ -612,6 +677,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
price21To60s, price21To60s,
rates: fallbackMarketRow?.rates, rates: fallbackMarketRow?.rates,
row: authorCell, row: authorCell,
selectionCheckbox,
singleCell, singleCell,
visibilityTargets: rowCells visibilityTargets: rowCells
} satisfies MarketRowDom } satisfies MarketRowDom
@@ -619,6 +685,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
}); });
return { return {
headerSelectionCheckbox,
rows rows
}; };
} }
@@ -640,7 +707,11 @@ function ensureSyntheticHeaderCell(
const nextCell = header.ownerDocument.createElement("div"); const nextCell = header.ownerDocument.createElement("div");
nextCell.dataset.marketHeaderCell = field; nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label; nextCell.textContent = label;
header.appendChild(nextCell); if (field === SELECTION_COLUMN_KEY) {
header.insertBefore(nextCell, header.firstChild);
} else {
header.appendChild(nextCell);
}
return nextCell; return nextCell;
} }
@@ -655,7 +726,11 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span"); const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
nextCell.dataset.marketRowCell = field; nextCell.dataset.marketRowCell = field;
row.appendChild(nextCell); if (field === SELECTION_COLUMN_KEY) {
row.insertBefore(nextCell, row.firstChild);
} else {
row.appendChild(nextCell);
}
return nextCell; return nextCell;
} }
@@ -679,7 +754,11 @@ function ensureDivHeaderCell(
nextCell.textContent = label; nextCell.textContent = label;
applyColumnWidth(nextCell, field); applyColumnWidth(nextCell, field);
applyPluginHeaderCellStyles(nextCell); applyPluginHeaderCellStyles(nextCell);
container.appendChild(nextCell); if (field === SELECTION_COLUMN_KEY) {
container.insertBefore(nextCell, templateCell);
} else {
container.appendChild(nextCell);
}
return nextCell; return nextCell;
} }
@@ -701,7 +780,11 @@ function ensureDivBodyColumn(
nextColumn.dataset.marketColumnGroup = field; nextColumn.dataset.marketColumnGroup = field;
applyColumnWidth(nextColumn, field); applyColumnWidth(nextColumn, field);
syncDivColumnCells(nextColumn, templateColumn, field, rowCount); syncDivColumnCells(nextColumn, templateColumn, field, rowCount);
container.appendChild(nextColumn); if (field === SELECTION_COLUMN_KEY) {
container.insertBefore(nextColumn, templateColumn);
} else {
container.appendChild(nextColumn);
}
return nextColumn; return nextColumn;
} }
@@ -719,25 +802,51 @@ function syncDivColumnCells(
const templateCells = getDirectContentCells(templateColumn); const templateCells = getDirectContentCells(templateColumn);
for (let index = 0; index < rowCount; index += 1) { for (let index = 0; index < rowCount; index += 1) {
const existingCell = getDirectContentCells(column)[index] ?? null; const existingCell = getDirectContentCells(column)[index] ?? null;
const templateCell =
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
if (existingCell) { if (existingCell) {
existingCell.dataset.marketRowCell = field; existingCell.dataset.marketRowCell = field;
applyPluginContentCellStyles(existingCell); applyPluginContentCellStyles(existingCell);
syncContentCellHeight(existingCell, templateCell);
continue; continue;
} }
const templateCell = const nextCell =
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null; field === SELECTION_COLUMN_KEY
const nextCell = templateCell ? templateCell
? cloneElementShallow(templateCell) ? createSelectionContentCell(templateCell)
: createBareContentCell(column.ownerDocument); : createBareContentCell(column.ownerDocument)
: templateCell
? cloneElementShallow(templateCell)
: createBareContentCell(column.ownerDocument);
nextCell.dataset.marketRowCell = field; nextCell.dataset.marketRowCell = field;
applyColumnWidth(nextCell, field); applyColumnWidth(nextCell, field);
applyPluginContentCellStyles(nextCell); applyPluginContentCellStyles(nextCell);
syncContentCellHeight(nextCell, templateCell);
nextCell.textContent = ""; nextCell.textContent = "";
column.appendChild(nextCell); column.appendChild(nextCell);
} }
} }
function syncContentCellHeight(
cell: HTMLElement,
templateCell: HTMLElement | null
): void {
if (!templateCell) {
return;
}
const measuredHeight = Math.round(templateCell.getBoundingClientRect().height);
const nextHeight =
measuredHeight > 0 ? `${measuredHeight}px` : templateCell.style.height;
if (nextHeight) {
cell.style.height = nextHeight;
} else {
cell.style.removeProperty("height");
}
}
function applyPluginHeaderCellStyles(cell: HTMLElement): void { function applyPluginHeaderCellStyles(cell: HTMLElement): void {
cell.style.display = "flex"; cell.style.display = "flex";
cell.style.alignItems = "center"; cell.style.alignItems = "center";
@@ -756,6 +865,54 @@ function applyPluginContentCellStyles(cell: HTMLElement): void {
cell.style.whiteSpace = "nowrap"; cell.style.whiteSpace = "nowrap";
} }
function ensureSelectionHeaderControl(cell: HTMLElement): HTMLInputElement {
cell.textContent = "";
cell.style.gap = "6px";
cell.style.justifyContent = "center";
const checkbox = ensureSelectionCheckbox(cell, "header");
const label = cell.querySelector(
'[data-market-selection-label="header"]'
) as HTMLElement | null;
if (label) {
label.textContent = "全选";
return checkbox;
}
const nextLabel = cell.ownerDocument.createElement("span");
nextLabel.dataset.marketSelectionLabel = "header";
nextLabel.textContent = "全选";
nextLabel.style.fontSize = "12px";
cell.appendChild(nextLabel);
return checkbox;
}
function ensureSelectionRowControl(cell: HTMLElement): HTMLInputElement {
cell.textContent = "";
cell.style.justifyContent = "center";
return ensureSelectionCheckbox(cell, "row");
}
function ensureSelectionCheckbox(
container: HTMLElement,
kind: "header" | "row"
): HTMLInputElement {
const existingCheckbox = container.querySelector(
`[data-market-selection-checkbox="${kind}"]`
) as HTMLInputElement | null;
if (existingCheckbox) {
existingCheckbox.type = "checkbox";
return existingCheckbox;
}
const checkbox = container.ownerDocument.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.marketSelectionCheckbox = kind;
checkbox.style.cursor = "pointer";
container.appendChild(checkbox);
return checkbox;
}
function getOwnerDocument(root: ParentNode): Document | null { function getOwnerDocument(root: ParentNode): Document | null {
if ("ownerDocument" in root && root.ownerDocument) { if ("ownerDocument" in root && root.ownerDocument) {
return root.ownerDocument; return root.ownerDocument;
@@ -982,6 +1139,21 @@ function getActionColumn(bodySection: HTMLElement): HTMLElement | null {
return columns[columns.length - 1] ?? null; return columns[columns.length - 1] ?? null;
} }
function getNativeAuthorColumn(authorSection: HTMLElement): HTMLElement | null {
return (
getDirectContentColumns(authorSection).find(
(column) =>
!column.dataset.marketColumnGroup &&
getDirectContentCells(column).some(
(cell) =>
cell.querySelector("a") ||
cell.querySelector(".author-nickname") ||
Boolean(cell.dataset.authorId)
)
) ?? null
);
}
function getDirectHeaderCells(section: Element): HTMLElement[] { function getDirectHeaderCells(section: Element): HTMLElement[] {
return Array.from(section.querySelectorAll(".header-cell")).filter( return Array.from(section.querySelectorAll(".header-cell")).filter(
(cell): cell is HTMLElement => (cell): cell is HTMLElement =>
@@ -1038,6 +1210,13 @@ function createBareContentCell(document: Document): HTMLElement {
return cell; return cell;
} }
function createSelectionContentCell(templateCell: HTMLElement): HTMLElement {
const cell = cloneElementShallow(templateCell);
cell.removeAttribute("data-testid");
cell.removeAttribute("data-author-id");
return cell;
}
function extractAuthorId(authorCell: HTMLElement): string { function extractAuthorId(authorCell: HTMLElement): string {
const explicitAuthorId = authorCell.dataset.authorId; const explicitAuthorId = authorCell.dataset.authorId;
if (explicitAuthorId) { if (explicitAuthorId) {
@@ -1097,33 +1276,9 @@ function readVueMarketRows(
continue; continue;
} }
return marketList.map((row) => { return marketList
const record = isRecord(row) ? row : {}; .map((row) => (isRecord(row) ? mapMarketListRow(row) : null))
const attributeDatas = readMarketAttributeDatas(record); .filter((row): row is MarketDataRow => row !== null);
const singleVideoAfterSearchRate = normalizeMarketListRate(
readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
);
return {
authorId:
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
readString(readMarketFieldValue(record, attributeDatas, "id")) ??
"",
authorName:
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
"",
exportFields: buildMarketExportFieldFallbacks(record, attributeDatas),
hasDirectRatesSource: true,
location: readMarketLocation(record, attributeDatas),
price21To60s: readMarketPrice21To60s(record, attributeDatas),
rates: singleVideoAfterSearchRate
? {
singleVideoAfterSearchRate
}
: undefined
};
});
} }
} }
@@ -1187,6 +1342,7 @@ function readSerializedMarketRows(
return { return {
authorId: readString(record.authorId) ?? "", authorId: readString(record.authorId) ?? "",
authorName: readString(record.authorName) ?? "", authorName: readString(record.authorName) ?? "",
coreUserId: readString(record.coreUserId) ?? undefined,
exportFields: readSerializedExportFields(record), exportFields: readSerializedExportFields(record),
hasDirectRatesSource: Boolean(singleVideoAfterSearchRate), hasDirectRatesSource: Boolean(singleVideoAfterSearchRate),
location: readString(record.location) ?? undefined, location: readString(record.location) ?? undefined,
@@ -1272,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 !== "个人视频看后搜率"
); );
} }
@@ -1434,6 +1590,11 @@ function readRateCellText(value: string | undefined): string {
} }
function applyColumnWidth(element: HTMLElement, field: string): void { function applyColumnWidth(element: HTMLElement, field: string): void {
if (field === SELECTION_COLUMN_KEY) {
element.style.minWidth = "56px";
element.style.width = "56px";
}
if (field === BACKEND_COLUMN_KEY) { if (field === BACKEND_COLUMN_KEY) {
element.style.minWidth = "240px"; element.style.minWidth = "240px";
element.style.width = "240px"; element.style.width = "240px";
@@ -1503,6 +1664,7 @@ function mergeMarketDataRows(
return { return {
authorId: preferredRow.authorId || baseRow.authorId, authorId: preferredRow.authorId || baseRow.authorId,
authorName: preferredRow.authorName || baseRow.authorName, authorName: preferredRow.authorName || baseRow.authorName,
coreUserId: mergeNonEmptyString(baseRow.coreUserId, preferredRow.coreUserId),
exportFields: mergeExportFieldMaps(baseRow.exportFields, preferredRow.exportFields), exportFields: mergeExportFieldMaps(baseRow.exportFields, preferredRow.exportFields),
hasDirectRatesSource: hasDirectRatesSource:
preferredRow.hasDirectRatesSource || baseRow.hasDirectRatesSource, preferredRow.hasDirectRatesSource || baseRow.hasDirectRatesSource,
@@ -222,6 +222,7 @@ function mergeMarketRecord(
...existingRecord, ...existingRecord,
...incomingRecord, ...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "", authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
exportFields: mergeFieldMap( exportFields: mergeFieldMap(
existingRecord.exportFields, existingRecord.exportFields,
incomingRecord.exportFields incomingRecord.exportFields
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
export const MARKET_REQUEST_SNAPSHOT_ATTRIBUTE =
"data-sces-market-request-snapshot";
const MARKET_SEARCH_ENDPOINT_PATH = "/gw/api/gsearch/search_for_author_square";
export interface MarketListRequestSnapshot {
body?: string;
headers?: Record<string, string>;
method: string;
url: string;
}
export function readMarketListRequestSnapshot(
document: Document
): MarketListRequestSnapshot | null {
const serializedSnapshot = document.documentElement.getAttribute(
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE
);
if (!serializedSnapshot) {
return readMarketListRequestSnapshotFromPageState(document);
}
try {
const parsedSnapshot = normalizeMarketListRequestSnapshot(
JSON.parse(serializedSnapshot)
);
if (!parsedSnapshot) {
return readMarketListRequestSnapshotFromPageState(document);
}
return parsedSnapshot;
} catch {
return readMarketListRequestSnapshotFromPageState(document);
}
}
export function writeMarketListRequestSnapshot(
document: Document,
snapshot: MarketListRequestSnapshot
): void {
document.documentElement.setAttribute(
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
JSON.stringify(snapshot)
);
}
function isMarketListRequestSnapshot(
value: unknown
): value is MarketListRequestSnapshot {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<MarketListRequestSnapshot>;
return (
typeof candidate.method === "string" &&
typeof candidate.url === "string" &&
(!("body" in candidate) || typeof candidate.body === "string") &&
(!("headers" in candidate) || isStringRecord(candidate.headers))
);
}
function normalizeMarketListRequestSnapshot(
value: unknown
): MarketListRequestSnapshot | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as Partial<MarketListRequestSnapshot> & {
headers?: Record<string, unknown>;
};
const normalizedSnapshot: Partial<MarketListRequestSnapshot> = {
body: typeof candidate.body === "string" ? candidate.body : undefined,
method: typeof candidate.method === "string" ? candidate.method : undefined,
url: typeof candidate.url === "string" ? candidate.url : undefined
};
if (candidate.headers && typeof candidate.headers === "object") {
normalizedSnapshot.headers = Object.fromEntries(
Object.entries(candidate.headers)
.filter(([, entry]) =>
["string", "number", "boolean"].includes(typeof entry)
)
.map(([key, entry]) => [key, String(entry)])
);
}
return isMarketListRequestSnapshot(normalizedSnapshot)
? normalizedSnapshot
: null;
}
function isStringRecord(value: unknown): value is Record<string, string> {
if (!value || typeof value !== "object") {
return false;
}
return Object.values(value).every((entry) => typeof entry === "string");
}
function readMarketListRequestSnapshotFromPageState(
document: Document
): MarketListRequestSnapshot | null {
const reqParams = findMarketReqParams(document);
if (!reqParams) {
return null;
}
return {
body: JSON.stringify(reqParams),
method: "POST",
url: buildMarketSearchUrl(document)
};
}
function findMarketReqParams(document: Document): Record<string, unknown> | null {
const marketRoot = document.querySelector(".base-author-list") as
| (Element & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
})
| null;
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return null;
}
const queue: unknown[] = Object.values(setupState);
while (queue.length > 0) {
const current = unwrapVueRef(queue.shift());
if (!isRecord(current)) {
continue;
}
const reqParams = unwrapVueRef(current.reqParams);
if (isRecord(reqParams)) {
return reqParams;
}
Object.values(current).forEach((value) => {
queue.push(value);
});
}
return null;
}
function buildMarketSearchUrl(document: Document): string {
if (
document.location?.origin &&
document.location.origin !== "null" &&
document.location.origin !== "about:blank"
) {
return document.location.origin.includes("xingtu.cn")
? MARKET_SEARCH_ENDPOINT_PATH
: new URL(MARKET_SEARCH_ENDPOINT_PATH, document.location.origin).toString();
}
return MARKET_SEARCH_ENDPOINT_PATH;
}
function unwrapVueRef(value: unknown): unknown {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+516
View File
@@ -0,0 +1,516 @@
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
import type { MarketRowSnapshot } from "./types";
export interface ParsedMarketListResponse {
currentPage?: number;
pageSize?: number;
records: MarketRowSnapshot[];
totalCount?: number;
totalPages?: number;
}
const PAGE_NUMBER_KEYS = [
"currentPage",
"page",
"pageNo",
"pageNum",
"page_no",
"page_num"
] as const;
const PAGE_SIZE_KEYS = [
"limit",
"pageSize",
"page_size",
"size"
] as const;
const TOTAL_COUNT_KEYS = [
"total",
"totalCount",
"total_count"
] as const;
const TOTAL_PAGE_KEYS = [
"pageCount",
"page_count",
"totalPage",
"totalPages",
"total_page",
"total_pages"
] as const;
export function mapMarketListRow(
row: Record<string, unknown>
): MarketRowSnapshot {
const attributeDatas = readMarketAttributeDatas(row);
const singleVideoAfterSearchRate = normalizeMarketListRate(
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
);
return {
authorId:
readString(readMarketFieldValue(row, attributeDatas, "star_id")) ??
readString(readMarketFieldValue(row, attributeDatas, "id")) ??
"",
authorName:
readString(readMarketFieldValue(row, attributeDatas, "nickname")) ??
readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ??
"",
coreUserId:
readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ??
undefined,
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
hasDirectRatesSource: true,
location: readMarketLocation(row, attributeDatas),
price21To60s: readMarketPrice21To60s(row, attributeDatas),
rates: singleVideoAfterSearchRate
? {
singleVideoAfterSearchRate
}
: undefined
};
}
export function parseMarketListResponse(
payload: unknown
): ParsedMarketListResponse | null {
const container = findMarketListContainer(payload);
if (!container) {
return null;
}
const marketList = readMarketListArray(container);
if (!marketList) {
return null;
}
return {
currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? undefined,
pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? undefined,
records: marketList
.map((row) => (isRecord(row) ? mapMarketListRow(row) : null))
.filter(
(row): row is MarketRowSnapshot =>
row !== null && Boolean(row.authorId || row.authorName)
),
totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? undefined,
totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? undefined
};
}
export function readKnownPaginationNumber(
value: unknown,
kind: "page" | "pageSize"
): number | null {
if (!isRecord(value)) {
return null;
}
return readKnownNumberDeep(value, kind === "page" ? PAGE_NUMBER_KEYS : PAGE_SIZE_KEYS);
}
function findMarketListContainer(value: unknown): Record<string, unknown> | null {
const queue: unknown[] = [value];
while (queue.length > 0) {
const current = queue.shift();
if (!isRecord(current)) {
continue;
}
if (readMarketListArray(current)) {
return current;
}
Object.values(current).forEach((entry) => {
queue.push(unwrapVueRef(entry));
});
}
return null;
}
function readMarketListArray(record: Record<string, unknown>): unknown[] | null {
const marketList = unwrapVueRef(record.marketList);
if (Array.isArray(marketList)) {
return marketList;
}
const authors = unwrapVueRef(record.authors);
if (Array.isArray(authors)) {
return authors;
}
return null;
}
function unwrapVueRef(value: unknown): unknown {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readMarketAttributeDatas(
record: Record<string, unknown>
): Record<string, unknown> {
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
}
function readMarketFieldValue(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>,
field: string
): unknown {
return record[field] ?? attributeDatas[field];
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function normalizeMarketListRate(value: unknown): string | null {
if (typeof value === "number") {
return normalizeFractionRateDisplay(String(value));
}
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
}
function normalizeExportCellText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function buildMarketExportFieldFallbacks(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): Record<string, string> | undefined {
const exportFields: Record<string, string> = {};
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
const authorType = buildMarketAuthorType(record, attributeDatas);
const contentTheme = buildMarketContentTheme(record, attributeDatas);
const connectedUsers = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
);
const followerCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
);
const expectedCpm = formatDecimalDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
);
const expectedPlayCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
);
const interactionRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
);
const finishRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
);
const burstRate = readBurstRateDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
);
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
assignExportField(exportFields, "达人信息", authorInfo);
assignExportField(exportFields, "代表视频", representativeVideo);
assignExportField(exportFields, "达人类型", authorType);
assignExportField(exportFields, "内容主题", contentTheme);
assignExportField(exportFields, "连接用户数", connectedUsers);
assignExportField(exportFields, "粉丝数", followerCount);
assignExportField(exportFields, "预期CPM", expectedCpm);
assignExportField(exportFields, "预期播放量", expectedPlayCount);
assignExportField(exportFields, "互动率", interactionRate);
assignExportField(exportFields, "完播率", finishRate);
assignExportField(exportFields, "爆文率", burstRate);
assignExportField(exportFields, "21-60s报价", price21To60s);
return Object.keys(exportFields).length > 0 ? exportFields : undefined;
}
function assignExportField(
exportFields: Record<string, string>,
key: string,
value: string | undefined
): void {
if (hasTextValue(value)) {
exportFields[key] = value;
}
}
function hasTextValue(value: string | undefined | null): boolean {
return Boolean(value && value.trim().length > 0);
}
function buildMarketAuthorInfo(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
const nickname =
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
"";
const parts = [
nickname,
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
].filter((value) => Boolean(value));
return parts.length > 0 ? parts.join(" ") : undefined;
}
function buildMarketAuthorType(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
const tagsRelation = readRecordLike(
readMarketFieldValue(record, attributeDatas, "tags_relation")
);
if (tagsRelation) {
const primaryTag = Object.keys(tagsRelation)[0];
if (hasTextValue(primaryTag)) {
return primaryTag;
}
}
return undefined;
}
function buildMarketContentTheme(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
const themes = readStringArray(
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
);
if (themes.length === 0) {
return undefined;
}
if (themes.length <= 2) {
return themes.join(" ");
}
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
}
function readMarketLocation(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined;
}
function readMarketPrice21To60s(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
return formatCurrencyValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
);
}
function readMarketRepresentativeVideo(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
for (const item of items) {
if (!isRecord(item)) {
continue;
}
const title = readString(item.title);
if (hasTextValue(title)) {
return normalizeExportCellText(title);
}
}
return undefined;
}
function readMarketGenderLabel(value: unknown): string | undefined {
const rawValue = typeof value === "number" ? String(value) : readString(value);
if (rawValue === "1") {
return "男";
}
if (rawValue === "2") {
return "女";
}
return undefined;
}
function readBurstRateDisplay(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
if (value <= 0) {
return "-";
}
return formatFractionPercent(value);
}
function formatCurrencyValue(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return `¥${value.toLocaleString("en-US", {
maximumFractionDigits: 0
})}`;
}
function formatWanValue(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return `${formatDecimalWithGrouping(value / 10000)}w`;
}
function formatFractionPercent(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return `${formatDecimalDisplay(value * 100)}%`;
}
function formatDecimalDisplay(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
useGrouping: false
});
}
function formatDecimalWithGrouping(value: number): string {
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0
});
}
function readNumericValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
return null;
}
function readStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue)
? parsedValue.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
return [];
}
function readArrayLike(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue) ? parsedValue : [];
} catch {
return [];
}
}
return [];
}
function readRecordLike(value: unknown): Record<string, unknown> | null {
if (isRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return isRecord(parsedValue) ? parsedValue : null;
} catch {
return null;
}
}
return null;
}
function readKnownNumber(
record: Record<string, unknown>,
keys: readonly string[]
): number | undefined {
for (const key of keys) {
const value = readNumericValue(record[key]);
if (value !== null) {
return value;
}
}
return undefined;
}
function readKnownNumberDeep(
value: unknown,
keys: readonly string[]
): number | null {
if (!isRecord(value)) {
return null;
}
const directValue = readKnownNumber(value, keys);
if (typeof directValue === "number") {
return directValue;
}
for (const nestedValue of Object.values(value)) {
const candidate =
readKnownNumberDeep(unwrapVueRef(nestedValue), keys);
if (typeof candidate === "number") {
return candidate;
}
}
return null;
}
+202
View File
@@ -1,6 +1,11 @@
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer"; import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
import {
writeMarketListRequestSnapshot
} from "./market-list-request-snapshot";
import { parseMarketListResponse } from "./market-list-row";
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__"; const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
const MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows"; const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type MarketRow = { type MarketRow = {
@@ -24,6 +29,7 @@ function installMarketPageBridge() {
} }
window[BRIDGE_MARKER] = true; window[BRIDGE_MARKER] = true;
installMarketRequestSnapshotBridge();
syncSerializedMarketRows(); syncSerializedMarketRows();
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
@@ -39,7 +45,16 @@ function installMarketPageBridge() {
}, 1000); }, 1000);
} }
function installMarketRequestSnapshotBridge() {
installFetchSnapshotBridge();
installXmlHttpRequestSnapshotBridge();
}
function syncSerializedMarketRows() { function syncSerializedMarketRows() {
if (typeof document === "undefined") {
return;
}
const nextSerializedRows = JSON.stringify(readSerializedMarketRows()); const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if ( if (
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
@@ -52,6 +67,131 @@ function syncSerializedMarketRows() {
} }
} }
function installFetchSnapshotBridge() {
if (typeof window.fetch !== "function") {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const requestSnapshot = readFetchSnapshot(input, init);
const response = await originalFetch(input, init);
if (requestSnapshot) {
const clonedResponse = response.clone();
void captureMarketSnapshotFromResponse(requestSnapshot, () =>
clonedResponse.json()
);
}
return response;
};
}
function installXmlHttpRequestSnapshotBridge() {
const OriginalXmlHttpRequest = window.XMLHttpRequest;
if (!OriginalXmlHttpRequest) {
return;
}
const originalOpen = OriginalXmlHttpRequest.prototype.open;
const originalSend = OriginalXmlHttpRequest.prototype.send;
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
OriginalXmlHttpRequest.prototype.open = function (
method: string,
url: string | URL,
...rest: unknown[]
) {
(
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
method: string;
url: string;
};
}
).__scesMarketSnapshot = {
headers: {},
method,
url: String(url)
};
return originalOpen.call(this, method, url, ...(rest as [boolean?, string?, string?]));
};
OriginalXmlHttpRequest.prototype.setRequestHeader = function (
name: string,
value: string
) {
(
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
};
}
).__scesMarketSnapshot?.headers &&
((this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
};
}).__scesMarketSnapshot!.headers[name] = value);
return originalSetRequestHeader.call(this, name, value);
};
OriginalXmlHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
const snapshotState = (
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
body?: string;
headers: Record<string, string>;
method: string;
url: string;
};
}
).__scesMarketSnapshot;
if (snapshotState) {
snapshotState.body = typeof body === "string" ? body : undefined;
this.addEventListener("load", () => {
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
return;
}
void captureMarketSnapshotFromResponse(snapshotState, async () =>
JSON.parse(this.responseText)
);
});
}
return originalSend.call(this, body);
};
}
async function captureMarketSnapshotFromResponse(
snapshot: {
body?: string;
headers?: Record<string, string>;
method: string;
url: string;
},
readPayload: () => Promise<unknown>
) {
if (!isMarketSearchRequest(snapshot.url)) {
return;
}
try {
const payload = await readPayload();
if (!parseMarketListResponse(payload)) {
return;
}
writeMarketListRequestSnapshot(document, {
body: snapshot.body,
headers: snapshot.headers,
method: snapshot.method,
url: snapshot.url
});
} catch {}
}
function readSerializedMarketRows() { function readSerializedMarketRows() {
const marketList = readMarketList(); const marketList = readMarketList();
return marketList return marketList
@@ -65,13 +205,66 @@ function readSerializedMarketRows() {
readString(row.star_id) ?? readString(attributeDatas.id) ?? "", readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
authorName: authorName:
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "", readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
singleVideoAfterSearchRate singleVideoAfterSearchRate
}; };
}) })
.filter((row) => Boolean(row.authorId || row.authorName)); .filter((row) => Boolean(row.authorId || row.authorName));
} }
function readFetchSnapshot(
input: RequestInfo | URL,
init?: RequestInit
): {
body?: string;
headers?: Record<string, string>;
method: string;
url: string;
} | null {
const request = input instanceof Request ? input : null;
const method = init?.method ?? request?.method ?? "GET";
const url = request?.url ?? String(input);
const body =
typeof init?.body === "string"
? init.body
: typeof request?.bodyUsed === "boolean" && request.bodyUsed
? undefined
: undefined;
const headers = serializeHeaders(init?.headers ?? request?.headers);
return {
body,
headers,
method,
url
};
}
function serializeHeaders(
headers: HeadersInit | undefined
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)])
);
}
function readMarketList(): MarketRow[] { function readMarketList(): MarketRow[] {
if (typeof document === "undefined") {
return [];
}
const marketRoot = document.querySelector(".base-author-list") as const marketRoot = document.querySelector(".base-author-list") as
| (HTMLElement & { | (HTMLElement & {
__vue__?: { __vue__?: {
@@ -102,6 +295,15 @@ function readMarketList(): MarketRow[] {
return []; return [];
} }
function isMarketSearchRequest(url: string): boolean {
return (
url === MARKET_SEARCH_REQUEST_PATH ||
url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
url.endsWith(MARKET_SEARCH_REQUEST_PATH)
);
}
function looksLikeMarketList(value: unknown[]): boolean { function looksLikeMarketList(value: unknown[]): boolean {
const firstRow = value[0]; const firstRow = value[0];
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow); return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
+152 -21
View File
@@ -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;
@@ -17,6 +23,8 @@ export interface PluginToolbarDom {
root: HTMLElement; root: HTMLElement;
} }
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
export function isPluginToolbarMounted( export function isPluginToolbarMounted(
root: HTMLElement, root: HTMLElement,
document: Document document: Document
@@ -29,12 +37,22 @@ export function ensurePluginToolbar(
document: Document, document: Document,
handlers: PluginToolbarHandlers handlers: PluginToolbarHandlers
): PluginToolbarDom { ): PluginToolbarDom {
ensurePluginActionButtonTheme(document);
const existingRoot = document.querySelector( const existingRoot = document.querySelector(
"[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");
@@ -43,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页");
@@ -61,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";
@@ -76,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,
@@ -92,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,
@@ -107,6 +167,9 @@ export function ensurePluginToolbar(
}); });
const toolbarDom = { const toolbarDom = {
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton, batchSubmitButton,
exportButton, exportButton,
exportCustomPagesInput, exportCustomPagesInput,
@@ -132,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,
@@ -214,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
@@ -230,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 {
@@ -410,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;
@@ -425,12 +503,19 @@ 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) => { [
applyPrimaryButtonStyles(button, Boolean(primaryButton)); controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton
].forEach((button) => {
applyPrimaryButtonStyles(button);
button.style.whiteSpace = "nowrap"; button.style.whiteSpace = "nowrap";
}); });
@@ -449,21 +534,18 @@ function applyNativeControlStyles(
} }
function applyPrimaryButtonStyles( function applyPrimaryButtonStyles(
button: HTMLButtonElement, button: HTMLButtonElement
isUsingNativePrimaryButtonClass: boolean
): void { ): void {
if (!isUsingNativePrimaryButtonClass) { button.style.backgroundColor = "#7f1d2d";
button.style.backgroundColor = "#fe346e"; button.style.border = "1px solid #7f1d2d";
button.style.border = "1px solid #fe346e"; button.style.borderRadius = "8px";
button.style.borderRadius = "8px"; button.style.color = "#ffffff";
button.style.color = "#ffffff"; button.style.height = "32px";
button.style.height = "32px"; button.style.padding = "0 15px";
button.style.padding = "0 15px";
} else {
button.style.color = "#ffffff";
}
button.style.boxSizing = "border-box"; button.style.boxSizing = "border-box";
button.style.fontWeight = "600";
button.style.transition =
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
} }
function applyStatusStyles(statusText: HTMLElement): void { function applyStatusStyles(statusText: HTMLElement): void {
@@ -474,6 +556,55 @@ function applyStatusStyles(statusText: HTMLElement): void {
statusText.style.whiteSpace = "nowrap"; statusText.style.whiteSpace = "nowrap";
} }
function ensurePluginActionButtonTheme(document: Document): void {
if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
style.textContent = `
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
background-color: #6d1627 !important;
border-color: #6d1627 !important;
}
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
[data-plugin-batch-submit="button"]:active:not(:disabled) {
background-color: #58111f !important;
border-color: #58111f !important;
transform: translateY(1px);
}
[data-plugin-export-audience-profile="button"]:focus-visible,
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
[data-plugin-audience-profile-fields="button"]:focus-visible,
[data-plugin-batch-submit="button"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
}
[data-plugin-export-audience-profile="button"]:disabled,
[data-plugin-export-audience-profile-by-id="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled,
[data-plugin-batch-submit="button"]:disabled {
background-color: #c89ca4 !important;
border-color: #c89ca4 !important;
color: rgba(255, 255, 255, 0.95) !important;
cursor: not-allowed !important;
opacity: 1 !important;
transform: none !important;
box-shadow: none !important;
}
`;
document.head.appendChild(style);
}
function normalizeText(value: string | null | undefined): string { function normalizeText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? ""; return value?.replace(/\s+/g, " ").trim() ?? "";
} }
+4
View File
@@ -61,6 +61,10 @@ export function createMarketResultStore() {
existingRecord.authorName = existingRecord.authorName =
mergeStringValue(existingRecord.authorName, row.authorName) ?? mergeStringValue(existingRecord.authorName, row.authorName) ??
existingRecord.authorName; existingRecord.authorName;
existingRecord.coreUserId = mergeStringValue(
existingRecord.coreUserId,
row.coreUserId
);
existingRecord.location = mergeStringValue( existingRecord.location = mergeStringValue(
existingRecord.location, existingRecord.location,
row.location row.location
@@ -0,0 +1,402 @@
import {
readMarketListRequestSnapshot,
type MarketListRequestSnapshot
} from "./market-list-request-snapshot";
import {
parseMarketListResponse,
readKnownPaginationNumber
} from "./market-list-row";
import type { MarketExportTarget, MarketRecord } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
interface SilentExportControllerOptions {
document: Document;
fetchImpl?: FetchLike;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
}
type PageSource = "body" | "none" | "url";
const PAGE_NUMBER_KEYS = [
"currentPage",
"page",
"pageNo",
"pageNum",
"page_no",
"page_num"
] as const;
export function createSilentExportController(
options: SilentExportControllerOptions
) {
const fetchImpl = options.fetchImpl ?? defaultFetch;
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[] | null> {
const snapshot = readMarketListRequestSnapshot(options.document);
if (!snapshot) {
return null;
}
const baseRequest = createPagedRequest(snapshot);
if (!baseRequest) {
return null;
}
const mergedRecords = new Map<string, MarketRecord>();
const maxPageCount = target.mode === "count" ? target.pageCount : 200;
let totalPagesHint: number | undefined;
for (let offset = 0; offset < maxPageCount; offset += 1) {
const pageNumber = baseRequest.initialPage + offset;
options.onProgress?.({
currentPage: offset + 1,
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
});
const payload = await fetchPagePayload(fetchImpl, baseRequest, pageNumber);
const parsedResponse = parseMarketListResponse(payload);
if (!parsedResponse) {
return null;
}
totalPagesHint = parsedResponse.totalPages ?? totalPagesHint;
if (parsedResponse.records.length === 0) {
break;
}
parsedResponse.records.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
if (target.mode === "count" && offset + 1 >= target.pageCount) {
break;
}
if (target.mode === "all") {
if (
typeof parsedResponse.totalPages === "number" &&
pageNumber >= parsedResponse.totalPages
) {
break;
}
if (
typeof parsedResponse.pageSize === "number" &&
parsedResponse.records.length < parsedResponse.pageSize
) {
break;
}
}
}
return Array.from(mergedRecords.values());
}
};
}
function createPagedRequest(
snapshot: MarketListRequestSnapshot
): {
initialPage: number;
pageSource: PageSource;
snapshot: MarketListRequestSnapshot;
} {
const bodyPage = readPageFromBody(snapshot.body);
if (bodyPage !== null) {
return {
initialPage: bodyPage,
pageSource: "body",
snapshot
};
}
const urlPage = readPageFromUrl(snapshot.url);
if (urlPage !== null) {
return {
initialPage: urlPage,
pageSource: "url",
snapshot
};
}
return {
initialPage: 1,
pageSource: "none",
snapshot
};
}
async function fetchPagePayload(
fetchImpl: FetchLike,
request: {
pageSource: PageSource;
snapshot: MarketListRequestSnapshot;
},
pageNumber: number
): Promise<unknown> {
const nextUrl =
request.pageSource === "url"
? mutateUrlPage(request.snapshot.url, pageNumber)
: request.snapshot.url;
const nextBody = mutateBodyPage(request.snapshot.body, pageNumber);
const response = await fetchImpl(nextUrl, {
body: nextBody,
credentials: "include",
headers: filterReplayHeaders(request.snapshot.headers, nextBody),
method: request.snapshot.method
});
if (!response.ok) {
throw new Error("静默导出请求失败");
}
return response.json();
}
function readPageFromUrl(url: string): number | null {
try {
const parsedUrl = new URL(url);
for (const key of PAGE_NUMBER_KEYS) {
const value = readNumericString(parsedUrl.searchParams.get(key));
if (value !== null) {
return value;
}
}
} catch {
return null;
}
return null;
}
function mutateUrlPage(url: string, pageNumber: number): string {
try {
const parsedUrl = new URL(url);
for (const key of PAGE_NUMBER_KEYS) {
if (!parsedUrl.searchParams.has(key)) {
continue;
}
parsedUrl.searchParams.set(key, String(pageNumber));
return parsedUrl.toString();
}
parsedUrl.searchParams.set("page", String(pageNumber));
return parsedUrl.toString();
} catch {
return url;
}
}
function readPageFromBody(body: string | undefined): number | null {
const parsedBody = parseBody(body);
if (!parsedBody) {
return null;
}
return readKnownPaginationNumber(parsedBody, "page");
}
function mutateBodyPage(body: string | undefined, pageNumber: number): string | undefined {
if (!body) {
return body;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return body;
}
try {
const parsedJson = JSON.parse(trimmedBody);
if (!replacePageNumberInValue(parsedJson, pageNumber) && isRecord(parsedJson)) {
parsedJson.page = pageNumber;
}
return JSON.stringify(parsedJson);
} catch {
const searchParams = new URLSearchParams(trimmedBody);
for (const key of PAGE_NUMBER_KEYS) {
if (!searchParams.has(key)) {
continue;
}
searchParams.set(key, String(pageNumber));
return searchParams.toString();
}
searchParams.set("page", String(pageNumber));
return searchParams.toString();
}
}
function parseBody(body: string | undefined): Record<string, unknown> | null {
if (!body) {
return null;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return null;
}
try {
const parsedBody = JSON.parse(trimmedBody);
return isRecord(parsedBody) ? parsedBody : null;
} catch {
const searchParams = new URLSearchParams(trimmedBody);
const payload: Record<string, unknown> = {};
searchParams.forEach((value, key) => {
payload[key] = value;
});
return payload;
}
}
function replacePageNumberInValue(value: unknown, pageNumber: number): boolean {
if (!isRecord(value)) {
return false;
}
let replaced = false;
PAGE_NUMBER_KEYS.forEach((key) => {
if (!(key in value)) {
return;
}
value[key] = pageNumber;
replaced = true;
});
if (replaced) {
return true;
}
return Object.values(value).some((entry) => replacePageNumberInValue(entry, pageNumber));
}
function filterReplayHeaders(
headers: Record<string, string> | undefined,
body: string | undefined
): HeadersInit | undefined {
const filteredHeaders = Object.fromEntries(
Object.entries(headers ?? {}).filter(([key]) => {
const normalizedKey = key.toLowerCase();
return normalizedKey !== "content-length" && normalizedKey !== "host";
})
);
if (body) {
if (!hasHeader(filteredHeaders, "accept")) {
filteredHeaders.Accept = "application/json, text/plain, */*";
}
if (!hasHeader(filteredHeaders, "content-type")) {
filteredHeaders["Content-Type"] = "application/json";
}
if (!hasHeader(filteredHeaders, "x-login-source")) {
filteredHeaders["x-login-source"] = "1";
}
if (!hasHeader(filteredHeaders, "agw-js-conv")) {
filteredHeaders["Agw-Js-Conv"] = "str";
}
}
return Object.keys(filteredHeaders).length > 0 ? filteredHeaders : undefined;
}
function hasHeader(headers: Record<string, string>, key: string): boolean {
return Object.keys(headers).some((headerKey) => headerKey.toLowerCase() === key);
}
function readNumericString(value: string | null): number | null {
if (!value) {
return null;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
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;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates),
status: incomingRecord.status ?? "idle"
};
}
return {
...existingRecord,
...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
exportFields: mergeFieldMap(
existingRecord.exportFields,
incomingRecord.exportFields
),
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
hasDirectRatesSource:
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
location: mergeStringValue(existingRecord.location, incomingRecord.location),
price21To60s: mergeStringValue(
existingRecord.price21To60s,
incomingRecord.price21To60s
),
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
status: incomingRecord.status ?? existingRecord.status
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
return hasTextValue(incoming) ? incoming : current;
}
function hasTextValue(value: string | undefined): boolean {
return Boolean(value && value.trim().length > 0);
}
+1
View File
@@ -22,6 +22,7 @@ export interface MarketRowSnapshot {
authorId: string; authorId: string;
authorName: string; authorName: string;
backendMetrics?: BackendMetrics; backendMetrics?: BackendMetrics;
coreUserId?: string;
exportFields?: Record<string, string>; exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean; hasDirectRatesSource?: boolean;
location?: string; location?: string;
+12 -1
View File
@@ -3,7 +3,14 @@
"name": "Star Chart Search Enhancer", "name": "Star Chart Search Enhancer",
"version": "0.2.0421.2", "version": "0.2.0421.2",
"description": "Bootstraps the Xingtu creator market content script.", "description": "Bootstraps the Xingtu creator market content script.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
"permissions": ["downloads", "identity", "storage"], "permissions": ["downloads", "identity", "storage"],
"icons": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
},
"host_permissions": [ "host_permissions": [
"http://*/*", "http://*/*",
"https://login-api.intelligrow.cn/*", "https://login-api.intelligrow.cn/*",
@@ -11,6 +18,10 @@
"https://*/*" "https://*/*"
], ],
"action": { "action": {
"default_icon": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png"
},
"default_popup": "popup/index.html" "default_popup": "popup/index.html"
}, },
"background": { "background": {
@@ -23,7 +34,7 @@
"https://*.xingtu.cn/ad/creator/market*" "https://*.xingtu.cn/ad/creator/market*"
], ],
"js": ["content/index.js"], "js": ["content/index.js"],
"run_at": "document_idle" "run_at": "document_start"
} }
], ],
"web_accessible_resources": [ "web_accessible_resources": [
+137 -5
View File
@@ -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>
+89
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function renderDevPanel( export function renderDevPanel(
root: HTMLElement, root: HTMLElement,
authState: AuthStateValue authState: AuthStateValue
+1 -1
View File
@@ -9,7 +9,7 @@ export interface AuthConfig {
const defaultAuthConfig: AuthConfig = { const defaultAuthConfig: AuthConfig = {
apiResource: "https://talent-search.intelligrow.cn", apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3", appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: true, enableDevAuthPanel: false,
logtoEndpoint: "https://login-api.intelligrow.cn", logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"] scopes: ["openid", "profile", "offline_access", "talent-search:read"]
}; };
+1 -1
View File
@@ -1 +1 @@
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.29:8083"; export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
+94
View File
@@ -0,0 +1,94 @@
export interface UpdateManifest {
guideUrl: string;
latestVersion: string;
minSupportedVersion: string;
publishedAt: string;
releaseNotes: string[];
zipUrl: string;
}
export function compareExtensionVersions(left: string, right: string): number {
const leftParts = parseVersionParts(left);
const rightParts = parseVersionParts(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] ?? 0;
const rightValue = rightParts[index] ?? 0;
if (leftValue !== rightValue) {
return leftValue - rightValue;
}
}
return 0;
}
export function parseUpdateManifest(value: unknown): UpdateManifest | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as Partial<UpdateManifest>;
if (
!isVersionString(candidate.latestVersion) ||
!isVersionString(candidate.minSupportedVersion) ||
!isHttpsUrl(candidate.zipUrl) ||
!isHttpsUrl(candidate.guideUrl) ||
typeof candidate.publishedAt !== "string" ||
!Array.isArray(candidate.releaseNotes) ||
!candidate.releaseNotes.every((note) => typeof note === "string")
) {
return null;
}
return {
guideUrl: candidate.guideUrl,
latestVersion: candidate.latestVersion,
minSupportedVersion: candidate.minSupportedVersion,
publishedAt: candidate.publishedAt,
releaseNotes: candidate.releaseNotes,
zipUrl: candidate.zipUrl
};
}
export async function fetchUpdateManifest(
manifestUrl: string,
fetchImpl: typeof fetch = fetch
): Promise<UpdateManifest> {
const response = await fetchImpl(manifestUrl, {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`update manifest request failed: ${response.status}`);
}
const manifest = parseUpdateManifest(await response.json());
if (!manifest) {
throw new Error("update manifest is invalid");
}
return manifest;
}
function parseVersionParts(value: string): number[] {
return value.split(".").map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
}
function isVersionString(value: unknown): value is string {
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
}
function isHttpsUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const UPDATE_MANIFEST_URL =
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, test, vi } from "vitest";
import {
AUDIENCE_PROFILE_TARGETS,
createAudienceProfileClient,
mapAudienceProfileResponse
} from "../src/content/market/audience-profile-client";
describe("audience-profile-client", () => {
test("loads connection user audience distributions from Xingtu", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => buildAudiencePayload(),
ok: true
}));
const client = createAudienceProfileClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
const result = await client.loadAudienceProfile({
authorId: "7294473194298146854",
authorName: "奇奇de海洋",
status: "success"
}, AUDIENCE_PROFILE_TARGETS.audience);
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=5",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
expect(result).toEqual(
expect.objectContaining({
status: "success",
gender: [
{ label: "男性", value: "71.7%" },
{ label: "女性", value: "28.3%" }
],
cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }])
})
);
});
test("loads fans and iron fan profiles from Xingtu fan distribution endpoint", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => buildAudiencePayload(),
ok: true
}));
const client = createAudienceProfileClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await client.loadAudienceProfile({
authorId: "7294473194298146854",
authorName: "奇奇de海洋",
status: "success"
}, AUDIENCE_PROFILE_TARGETS.longtimeFans);
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/get_author_fans_distribution?o_author_id=7294473194298146854&platform_source=1&author_type=5",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
test("maps Xingtu audience distribution payload into named profile sections", () => {
const result = mapAudienceProfileResponse(buildAudiencePayload());
expect(result).toEqual(
expect.objectContaining({
status: "success",
age: [
{ label: "18-23", value: "20%" },
{ label: "24-30", value: "30%" },
{ label: "31-40", value: "50%" }
],
province: [
{ label: "广东", value: "60%" },
{ label: "浙江", value: "40%" }
],
cityTier: [{ label: "一线城市", value: "100%" }],
interest: [{ label: "随拍", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }]
})
);
});
});
function buildAudiencePayload() {
return {
base_resp: {
status_code: 0,
status_message: ""
},
distributions: [
{
distribution_list: [
{ distribution_key: "male", distribution_value: 717 },
{ distribution_key: "female", distribution_value: 283 }
],
type_display: "性别分布"
},
{
distribution_list: [
{ distribution_key: "31-40", distribution_value: 50 },
{ distribution_key: "18-23", distribution_value: 20 },
{ distribution_key: "24-30", distribution_value: 30 }
],
type_display: "年龄分布"
},
{
distribution_list: [
{ distribution_key: "浙江", distribution_value: 40 },
{ distribution_key: "广东", distribution_value: 60 }
],
type_display: "省份分布"
},
{
distribution_list: [
{ distribution_key: "广州", distribution_value: 304 },
{ distribution_key: "北京", distribution_value: 291 },
{ distribution_key: "上海", distribution_value: 405 }
],
type_display: "城市分布"
},
{
distribution_list: [{ distribution_key: "一线", distribution_value: 1 }],
type_display: "城市等级分布"
},
{
distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }],
type_display: "兴趣分布"
},
{
distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }],
type_display: "八大人群分布"
}
]
};
}
+331
View File
@@ -0,0 +1,331 @@
import { describe, expect, test } from "vitest";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
listAudienceProfileCsvHeaders
} from "../src/content/market/audience-profile-csv";
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
describe("audience-profile-csv", () => {
test("exports only requested profile distribution columns", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [
{ label: "男性", value: "71.7%" },
{ label: "女性", value: "28.3%" }
],
status: "success"
},
fans: {
age: [{ label: "31-40", value: "40%" }],
cityTier: [{ label: "一线城市", value: "80%" }],
crowd: [{ label: "都市蓝领", value: "60%" }],
gender: [
{ label: "男性", value: "60%" },
{ label: "女性", value: "40%" }
],
status: "success"
},
longtimeFans: {
age: [{ label: "31-40", value: "30%" }],
cityTier: [{ label: "一线城市", value: "70%" }],
crowd: [{ label: "都市蓝领", value: "50%" }],
status: "success"
}
},
businessAbility: {
estimates: {
oneToTwenty: {
expectedCpe: "2.1",
expectedCpm: "120.0",
expectedPlay: "250w",
hotRate: "100%"
},
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
averageComment: "4.5w",
averageDuration: "150s",
averageLike: "113.2w",
averageShare: "26.5w",
finishRate: "15.8%",
interactionRate: "3.9%",
medianPlay: "3738.4w",
publishedItems: "<5"
},
xingtuVideo: {
averageComment: "5.1w",
averageDuration: "170s",
averageLike: "150.3w",
averageShare: "68.4w",
finishRate: "19.9%",
interactionRate: "5.5%",
medianPlay: "4059.7w",
publishedItems: "<5"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success"
}
} satisfies AudienceProfileExportRow
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toContain("达人信息,连接用户数");
expect(headerLine).not.toContain("抓取状态");
expect(headerLine).not.toContain("失败原因");
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
expect(headerLine).toContain("内容数据-星图视频-平均转发");
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
expect(headerLine).not.toContain("商业能力-个人视频-播放量中位数");
expect(headerLine).not.toContain("商业能力-20-60s视频-预期CPM");
expect(headerLine).toContain("观众画像-男性占比");
expect(headerLine).toContain("粉丝画像-女性占比");
expect(headerLine).not.toContain("铁粉画像-男性占比");
expect(headerLine).toContain("观众画像-31-40占比");
expect(headerLine).toContain("粉丝画像-一线城市占比");
expect(headerLine).toContain("铁粉画像-都市蓝领占比");
expect(headerLine).not.toContain("观众画像-新一线城市占比");
expect(headerLine).not.toContain("粉丝画像-新一线城市占比");
expect(headerLine).not.toContain("铁粉画像-新一线城市占比");
expect(headerLine).not.toContain("省份");
expect(headerLine).not.toContain("地域TOP");
expect(headerLine).not.toContain("兴趣TOP");
expect(rowLine).toContain("71.7%");
expect(rowLine).toContain("60%");
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
});
test("leaves distribution cells empty when profile loading fails", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: {
failureReason: "request-failed",
status: "failed"
},
fans: {
failureReason: "timeout",
status: "failed"
},
longtimeFans: {
status: "failed"
}
},
record: {
authorId: "123",
authorName: "达人 A",
status: "success"
}
} satisfies AudienceProfileExportRow
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).not.toContain("失败");
expect(rowLine).not.toContain("request-failed");
expect(rowLine).not.toContain("timeout");
});
test("fills missing fixed distribution buckets with zero for successful profiles", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: { status: "success" },
fans: { status: "success" },
longtimeFans: {
age: [
{ label: "18-23", value: "11.1%" },
{ label: "24-30", value: "33.3%" },
{ label: "31-40", value: "55.6%" }
],
cityTier: [
{ label: "一线城市", value: "10%" },
{ label: "二线城市", value: "20%" },
{ label: "三线城市", value: "40%" },
{ label: "四线城市", value: "30%" }
],
crowd: [
{ label: "精致妈妈", value: "30%" },
{ label: "新锐白领", value: "20%" },
{ label: "资深中产", value: "10%" },
{ label: "都市蓝领", value: "20%" },
{ label: "小镇中老年", value: "10%" },
{ label: "小镇青年", value: "10%" }
],
status: "success"
}
},
record: {
authorId: "123",
authorName: "达人 A",
status: "success"
}
} satisfies AudienceProfileExportRow
]);
expect(readCsvValue(csv, "铁粉画像-41-50占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-50+占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-五线城市占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-都市银发占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
expect(csv.split("\n")[0]).not.toContain("新一线城市占比");
});
test("filters export columns by selected headers", () => {
const row = buildSuccessRow();
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: [
"内容数据-个人视频-播放量中位数",
"观众画像-男性占比"
]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
);
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
expect(headerLine).not.toContain("秒思api-看后搜数");
expect(headerLine).not.toContain("粉丝画像-女性占比");
});
test("always keeps fixed id export headers when filtering", () => {
const row = buildSuccessRow({
exportFields: {
ID: "123",
: "达人 A",
: "成功",
: ""
}
});
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
);
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
});
test("lists headers for field picker defaults", () => {
expect(listAudienceProfileCsvHeaders([buildSuccessRow()])).toEqual(
expect.arrayContaining([
"达人信息",
"连接用户数",
"秒思api-看后搜数",
"内容数据-个人视频-播放量中位数",
"效果预估-20-60s视频-预期CPM",
"观众画像-男性占比",
"铁粉画像-小镇青年占比"
])
);
});
test("groups selectable profile export fields", () => {
expect(listAudienceProfileSelectableFieldGroups()).toEqual(
expect.arrayContaining([
expect.objectContaining({
headers: expect.arrayContaining(["秒思api-看后搜数"]),
label: "秒思api数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
label: "内容数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["效果预估-20-60s视频-预期CPM"]),
label: "效果预估"
}),
expect.objectContaining({
headers: expect.arrayContaining(["观众画像-男性占比"]),
label: "观众画像"
})
])
);
});
});
function readCsvValue(csv: string, header: string): string {
const [headerLine, rowLine] = csv.split("\n");
const headers = headerLine.split(",");
const values = rowLine.split(",");
const index = headers.indexOf(header);
expect(index).toBeGreaterThanOrEqual(0);
return values[index] ?? "";
}
function buildSuccessRow(
overrides: Partial<AudienceProfileExportRow["record"]> = {}
): AudienceProfileExportRow {
return {
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [{ label: "男性", value: "71.7%" }],
status: "success"
},
fans: { status: "success" },
longtimeFans: { status: "success" }
},
businessAbility: {
estimates: {
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
medianPlay: "3738.4w"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success",
...overrides
}
};
}
+1 -1
View File
@@ -7,7 +7,7 @@ describe("auth-config", () => {
expect(readAuthConfig()).toEqual({ expect(readAuthConfig()).toEqual({
apiResource: "https://talent-search.intelligrow.cn", apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3", appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: true, enableDevAuthPanel: false,
logtoEndpoint: "https://login-api.intelligrow.cn", logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: [ scopes: [
"openid", "openid",
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test, vi } from "vitest";
import {
buildAuthorBaseInfoUrl,
createAuthorBaseClient,
mapAuthorBaseInfoResponse
} from "../src/content/market/author-base-client";
describe("author-base-client", () => {
test("builds Xingtu author base info url", () => {
expect(
buildAuthorBaseInfoUrl("6866044569306267651", "https://www.xingtu.cn")
).toBe(
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true"
);
});
test("maps author nickname into a market record", () => {
expect(mapAuthorBaseInfoResponse("6866044569306267651", {
base_resp: { status_code: 0, status_message: "Success" },
nick_name: "小九儿"
})).toEqual({
authorId: "6866044569306267651",
authorName: "小九儿",
status: "success"
});
});
test("loads author base info from Xingtu", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({
base_resp: { status_code: 0, status_message: "Success" },
nick_name: "小九儿"
}),
ok: true
}));
const client = createAuthorBaseClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await expect(client.loadAuthorBaseInfo("6866044569306267651")).resolves.toEqual({
authorId: "6866044569306267651",
authorName: "小九儿",
status: "success"
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { parseAuthorIds } from "../src/content/market/author-id-input";
describe("author-id-input", () => {
test("parses newline comma and space separated Xingtu author ids", () => {
expect(parseAuthorIds(`
6866044569306267651
7040323176106033165,7088592143119286285
7222310247979810854
`)).toEqual({
duplicates: [],
ids: [
"6866044569306267651",
"7040323176106033165",
"7088592143119286285",
"7222310247979810854"
],
invalidTokens: []
});
});
test("deduplicates ids and reports invalid tokens", () => {
expect(parseAuthorIds(`
6866044569306267651
bad-id
123
6866044569306267651
`)).toEqual({
duplicates: ["6866044569306267651"],
ids: ["6866044569306267651"],
invalidTokens: ["bad-id", "123"]
});
});
});
+21
View File
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
); );
}); });
test("returns unauthenticated state when the access token cannot be read", async () => {
const controller = createAuthController({
authClient: {
getAccessToken: vi.fn(async () => {
throw new Error("token expired");
}),
getIdTokenClaims: vi.fn(),
isAuthenticated: vi.fn(async () => true),
signIn: vi.fn(),
signOut: vi.fn()
}
});
await expect(controller.getAuthState()).resolves.toEqual(
expect.objectContaining({
isAuthenticated: false,
lastError: "token expired"
})
);
});
test("delegates sign in to the auth client", async () => { 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({
+46 -2
View File
@@ -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
@@ -164,7 +208,6 @@ describe("background-index", () => {
{ {
payload: { payload: {
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@@ -182,9 +225,10 @@ describe("background-index", () => {
expect(submitBatch).toHaveBeenCalledWith( expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
batchId: "批次A-2026-04-22T12:30:00.000Z" batchName: "批次A"
}) })
); );
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
expect(sendResponse).toHaveBeenCalledWith({ expect(sendResponse).toHaveBeenCalledWith({
ok: true, ok: true,
type: "batch:ack", type: "batch:ack",
+8 -4
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload"; import { createBatchPayload } from "../src/content/market/batch-payload";
describe("batch-payload", () => { describe("batch-payload", () => {
test("builds a batch id from the user id and timestamp", () => { test("builds the batch payload without a client-side batch id", () => {
const payload = createBatchPayload({ const payload = createBatchPayload({
authState: { authState: {
isAuthenticated: true, isAuthenticated: true,
@@ -16,17 +16,21 @@ describe("batch-payload", () => {
batchName: "618达人筛选第一批", batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
records: [ records: [
{ authorId: "111", authorName: "达人A", status: "success" }, {
authorId: "111",
authorName: "达人A",
coreUserId: "core-111",
status: "success"
},
{ authorId: "222", authorName: "达人B", status: "success" } { authorId: "222", authorName: "达人B", status: "success" }
] ]
}); });
expect(payload).toEqual({ expect(payload).toEqual({
authors: [ authors: [
{ authorId: "111", authorName: "达人A" }, { authorId: "111", authorName: "达人A", authorUid: "core-111" },
{ authorId: "222", authorName: "达人B" } { authorId: "222", authorName: "达人B" }
], ],
batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批", batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
+1 -5
View File
@@ -5,7 +5,7 @@ import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => { describe("batch-submit-client", () => {
test("exports the default batch submit base url", () => { test("exports the default batch submit base url", () => {
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.29:8083"); expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
}); });
test("posts the batch payload with a Bearer token", async () => { test("posts the batch payload with a Bearer token", async () => {
@@ -36,7 +36,6 @@ describe("batch-submit-client", () => {
await client.submitBatch({ await client.submitBatch({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@@ -49,7 +48,6 @@ describe("batch-submit-client", () => {
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@@ -87,7 +85,6 @@ describe("batch-submit-client", () => {
await expect( await expect(
client.submitBatch({ client.submitBatch({
authors: [], authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@@ -115,7 +112,6 @@ describe("batch-submit-client", () => {
await expect( await expect(
client.submitBatch({ client.submitBatch({
authors: [], authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, test, vi } from "vitest";
import {
buildBusinessAbilityEstimateUrl,
buildBusinessAbilityVideoUrl,
createBusinessAbilityClient,
mapBusinessAbilityEstimateResponse,
mapBusinessAbilityVideoResponse
} from "../src/content/market/business-ability-client";
describe("business-ability-client", () => {
test("builds commercial ability urls used by the Xingtu detail page", () => {
expect(
buildBusinessAbilityVideoUrl(
"6724241209444794382",
"https://www.xingtu.cn",
2
)
).toBe(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
);
expect(
buildBusinessAbilityEstimateUrl(
"6724241209444794382",
"https://www.xingtu.cn"
)
).toBe(
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
);
});
test("maps video content metrics into page-style display values", () => {
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
averageComment: "5.1w",
averageDuration: "170s",
averageLike: "150.3w",
averageShare: "68.4w",
finishRate: "19.9%",
interactionRate: "5.5%",
medianPlay: "4059.7w",
publishedItems: "<5"
});
});
test("maps duration estimates into page-style display values", () => {
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
oneToTwenty: {
expectedCpe: "2.1",
expectedCpm: "120.0",
expectedPlay: "250w",
hotRate: "100%"
},
overSixty: {
expectedCpe: "4.2",
expectedCpm: "240.0",
expectedPlay: "250w",
hotRate: "100%"
},
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "100%"
}
});
});
test("keeps decimal CPM values and marks missing hot rate", () => {
expect(mapBusinessAbilityEstimateResponse({
base_resp: { status_code: 0, status_message: "" },
cpe_1_20: "1.6347",
cpe_20_60: "2.002",
cpe_60: "2.104",
cpm_1_20: "21.7955",
cpm_20_60: "27.5628",
cpm_60: "29.877",
vv: "1010234"
})).toEqual({
oneToTwenty: {
expectedCpe: "1.6",
expectedCpm: "21.8",
expectedPlay: "101w",
hotRate: "缺失"
},
overSixty: {
expectedCpe: "2.1",
expectedCpm: "29.9",
expectedPlay: "101w",
hotRate: "缺失"
},
twentyToSixty: {
expectedCpe: "2",
expectedCpm: "27.6",
expectedPlay: "101w",
hotRate: "缺失"
}
});
});
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
const requestedUrls: string[] = [];
const fetchImpl = vi.fn(async (input: string) => {
requestedUrls.push(input);
return {
json: async () =>
input.includes("get_author_commerce_spread_info")
? buildEstimatePayload()
: buildVideoPayload(),
ok: true
};
});
const client = createBusinessAbilityClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await expect(
client.loadBusinessAbility({
authorId: "6724241209444794382",
authorName: "李蠕蠕",
status: "success"
})
).resolves.toMatchObject({
estimates: expect.objectContaining({
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
}),
status: "success",
videos: {
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
}
});
expect(requestedUrls).toEqual([
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
]);
});
});
function buildVideoPayload() {
return {
avg_duration: 17002,
base_resp: { status_code: 0, status_message: "" },
comment_avg: 51404,
interact_rate: { value: 551 },
item_num: 2,
like_avg: 1503028,
play_mid: 40596960,
play_over_rate: { value: 1991 },
share_avg: 684318
};
}
function buildEstimatePayload() {
return {
base_resp: { status_code: 0, status_message: "" },
cpe_1_20: "2.1035",
cpe_20_60: "3.7161",
cpe_60: "4.2069",
cpm_1_20: "119.9976",
cpm_20_60: "211.9958",
cpm_60: "239.9953",
platform_hot_rate: "1",
vv: "2500049"
};
}
+21 -21
View File
@@ -14,14 +14,14 @@ describe("csv-exporter", () => {
"达人名称", "达人名称",
"地区", "地区",
"21-60s报价", "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,,,,,,,,");
+56
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import manifest from "../src/manifest.json"; import manifest from "../src/manifest.json";
import { createManifest } from "../scripts/manifest.mjs";
describe("manifest", () => { describe("manifest", () => {
test("injects the content script on the www Xingtu market page", () => { test("injects the content script on the www Xingtu market page", () => {
@@ -9,12 +10,16 @@ describe("manifest", () => {
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/) expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
]) ])
); );
expect(manifest.content_scripts?.[0]?.run_at).toBe("document_start");
}); });
test("declares the downloads and auth permissions plus background worker", () => { test("declares the downloads and auth permissions plus background worker", () => {
expect(manifest.permissions).toEqual( expect(manifest.permissions).toEqual(
expect.arrayContaining(["downloads", "identity", "storage"]) expect.arrayContaining(["downloads", "identity", "storage"])
); );
expect(manifest.key).toBe(
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB"
);
expect(manifest.host_permissions).toEqual( expect(manifest.host_permissions).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"http://*/*", "http://*/*",
@@ -26,4 +31,55 @@ describe("manifest", () => {
expect(manifest.background?.service_worker).toBe("background/index.js"); expect(manifest.background?.service_worker).toBe("background/index.js");
expect(manifest.action?.default_popup).toBe("popup/index.html"); expect(manifest.action?.default_popup).toBe("popup/index.html");
}); });
test("builds a release manifest with narrowed host permissions", () => {
const releaseManifest = createManifest({ target: "release" });
expect(releaseManifest.permissions).toEqual(
expect.arrayContaining(["downloads", "identity", "storage"])
);
expect(releaseManifest.host_permissions).toEqual([
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*",
"https://*/*"
]);
expect(releaseManifest.host_permissions).not.toEqual(
expect.arrayContaining(["http://*/*", "http://127.0.0.1:4319/*"])
);
});
test("builds a release manifest with extension icons", () => {
const releaseManifest = createManifest({ target: "release" });
expect(releaseManifest.icons).toEqual({
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
});
expect(releaseManifest.key).toBe(manifest.key);
expect(releaseManifest.action?.default_icon).toEqual({
"16": "assets/icons/icon-16.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;
}
}
});
}); });
+20
View File
@@ -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("登录已过期,请重新登录");
});
}); });
File diff suppressed because it is too large Load Diff
+131 -5
View File
@@ -39,6 +39,9 @@ describe("market-dom-sync", () => {
const table = syncMarketTable(document); const table = syncMarketTable(document);
expect(table).not.toBeNull(); expect(table).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="selection"]')
).not.toBeNull();
expect( expect(
document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]') document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
).not.toBeNull(); ).not.toBeNull();
@@ -63,7 +66,9 @@ describe("market-dom-sync", () => {
expect( expect(
document.querySelector('[data-market-header-cell="cpSearch"]') document.querySelector('[data-market-header-cell="cpSearch"]')
).not.toBeNull(); ).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16); expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18);
expect(table?.headerSelectionCheckbox).not.toBeNull();
expect(table?.rows[0]?.selectionCheckbox).not.toBeNull();
}); });
test("renders loading, success, missing, and failed states", () => { test("renders loading, success, missing, and failed states", () => {
@@ -170,9 +175,13 @@ describe("market-dom-sync", () => {
throw new Error("Expected market table"); throw new Error("Expected market table");
} }
expect(table.headerSelectionCheckbox).not.toBeNull();
expect(table.rows[0]?.selectionCheckbox).not.toBeNull();
expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]); expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
expect(readSelectionHeaderText()).toBe("全选");
expect(readSelectionRowCheckboxCount()).toBe(2);
expect(readPluginHeaderTexts()).toEqual([ expect(readPluginHeaderTexts()).toEqual([
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率", "个人视频看后搜率",
"看后搜率", "看后搜率",
"看后搜数", "看后搜数",
@@ -284,6 +293,66 @@ describe("market-dom-sync", () => {
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标"); expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
}); });
test("keeps reading native author rows after the selection column is injected", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)?.rows.map((row) => row.authorId)).toEqual([
"111",
"222"
]);
const table = syncMarketTable(document);
expect(table?.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(readAuthorNames()).toEqual(["达人 A", "达人 B"]);
});
test("inserts the selection column before the native author column", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(readAuthorSectionColumnKeys()).toEqual(["selection", "author"]);
});
test("keeps the selection cells aligned to the native author row heights", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(readSelectionCellHeights()).toEqual(["120px", "120px"]);
});
test("resyncs selection cell heights when native author row heights change", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)).not.toBeNull();
expect(readSelectionCellHeights()).toEqual(["120px", "120px"]);
const authorCells = Array.from(
document.querySelectorAll('[data-testid="author-section"] .content-column:not([data-market-column-group="selection"]) > .content-cell')
) as HTMLElement[];
const middleCells = Array.from(
document.querySelectorAll('.middle-columns .content-column > .content-cell')
) as HTMLElement[];
const rightCells = Array.from(
document.querySelectorAll('[data-testid="right-section"] .content-column > .content-cell')
) as HTMLElement[];
[...authorCells, ...middleCells, ...rightCells].forEach((cell) => {
cell.style.height = "152px";
});
expect(syncMarketTable(document)).not.toBeNull();
expect(readSelectionCellHeights()).toEqual(["152px", "152px"]);
});
test("uses native-like alignment styles for plugin cells", () => { test("uses native-like alignment styles for plugin cells", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes(); document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
@@ -926,15 +995,50 @@ function readScrollHintText() {
); );
} }
function readAuthorNames() { function readSelectionHeaderText() {
const authorColumn = document.querySelector( return (
'[data-testid="author-section"] .content-column' document
.querySelector('[data-market-header-cell="selection"]')
?.textContent?.trim() ?? ""
); );
}
function readSelectionRowCheckboxCount() {
return document.querySelectorAll('[data-market-selection-checkbox="row"]').length;
}
function readAuthorNames() {
const authorColumn = Array.from(
document.querySelectorAll('[data-testid="author-section"] > .content-column')
).find((column) => (column as HTMLElement).querySelector("a")) as Element | null;
return readVisualCells(authorColumn).map( return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? "" (cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
); );
} }
function readAuthorSectionColumnKeys() {
return Array.from(
document.querySelectorAll('[data-testid="author-section"] > .content-column'),
(column) => {
const element = column as HTMLElement;
if (element.dataset.marketColumnGroup) {
return element.dataset.marketColumnGroup;
}
return element.querySelector("a") ? "author" : "unknown";
}
);
}
function readSelectionCellHeights() {
return Array.from(
document.querySelectorAll(
'[data-testid="author-section"] [data-market-column-group="selection"] > .content-cell'
),
(cell) => (cell as HTMLElement).style.height
);
}
function readAuthorRowHiddenStates() { function readAuthorRowHiddenStates() {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid^="author-cell-"]'), document.querySelectorAll('[data-testid^="author-cell-"]'),
@@ -967,3 +1071,25 @@ function readVisualCells(root: Element | null): HTMLElement[] {
return cells.indexOf(left) - cells.indexOf(right); return cells.indexOf(left) - cells.indexOf(right);
}); });
} }
function createRect({
height,
top
}: {
height: number;
top: number;
}): DOMRect {
return {
bottom: top + height,
height,
left: 0,
right: 0,
toJSON() {
return {};
},
top,
width: 0,
x: 0,
y: top
} as DOMRect;
}
+167
View File
@@ -0,0 +1,167 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://www.xingtu.cn/ad/creator/market"}
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
describe("market-page-bridge", () => {
beforeEach(() => {
document.body.innerHTML = '<div class="base-author-list"></div>';
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
vi.spyOn(window, "setInterval").mockReturnValue(0 as unknown as number);
});
afterEach(() => {
delete (
window as Window & {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
vi.restoreAllMocks();
vi.resetModules();
});
test("ignores non-search market list responses when capturing the export snapshot", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "推荐达人"
},
star_id: "recommend-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gauthor/demander_get_recommend_author_lists_v2", {
headers: {
Accept: "application/json, text/plain, */*"
},
method: "GET"
});
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.getAttribute("data-sces-market-request-snapshot")
).toBeNull();
});
test("captures the search_for_author_square request for silent export", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "搜索达人"
},
star_id: "search-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gsearch/search_for_author_square", {
body: JSON.stringify({
page_param: {
page: "1"
}
}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
});
await Promise.resolve();
await Promise.resolve();
expect(
JSON.parse(
document.documentElement.getAttribute(
"data-sces-market-request-snapshot"
) ?? "null"
)
).toEqual(
expect.objectContaining({
body: JSON.stringify({
page_param: {
page: "1"
}
}),
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
});
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"
})
]);
});
});
+1 -2
View File
@@ -53,7 +53,6 @@ describe("mock-protected-api", () => {
const response = await fetch(`${server.baseUrl}/api/mock/batches`, { const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
body: JSON.stringify({ body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@@ -71,7 +70,7 @@ describe("mock-protected-api", () => {
await expect(response.json()).resolves.toEqual( await expect(response.json()).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
acceptedCount: 1, acceptedCount: 1,
batchId: "批次A-2026-04-22T12:30:00.000Z", batchId: null,
ok: true, ok: true,
source: "mock-batch-submit" source: "mock-batch-submit"
}) })
+95
View File
@@ -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>";
+30
View File
@@ -0,0 +1,30 @@
import path from "node:path";
import { describe, expect, test } from "vitest";
import { buildReleaseUploadTargets } from "../scripts/release-assets.mjs";
describe("release-assets", () => {
test("maps release files to the COS object keys", () => {
expect(
buildReleaseUploadTargets({
projectRoot: "/repo",
releaseVersion: "0.0525.1"
})
).toEqual([
{
cosKey: "star-chart-search-enhancer/latest.json",
localPath: path.join("/repo", "release", "latest.json")
},
{
cosKey:
"star-chart-search-enhancer/releases/0.0525.1/star-chart-search-enhancer-internal.zip",
localPath: path.join("/repo", "release", "star-chart-search-enhancer-internal.zip")
},
{
cosKey:
"star-chart-search-enhancer/releases/0.0525.1/星图增强插件-超简单安装使用指南.pdf",
localPath: path.join("/repo", "release", "星图增强插件-超简单安装使用指南.pdf")
}
]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import {
normalizeReleaseVersionTag,
resolveReleaseVersion
} from "../scripts/release-version.mjs";
describe("release-version", () => {
test("normalizes a tag by stripping a leading v", () => {
expect(normalizeReleaseVersionTag("v0.0525.1")).toBe("0.0525.1");
expect(normalizeReleaseVersionTag("0.0525.1")).toBe("0.0525.1");
});
test("prefers EXTENSION_VERSION over DRONE_TAG and fallback", () => {
expect(
resolveReleaseVersion(
{
DRONE_TAG: "0.0525.2",
EXTENSION_VERSION: "v0.0525.3"
},
"0.2.0421.2"
)
).toBe("0.0525.3");
});
});
+268
View File
@@ -0,0 +1,268 @@
// @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { createSilentExportController } from "../src/content/market/silent-export-controller";
describe("silent-export-controller", () => {
test("replays exports from the live market page state when the request snapshot attribute is missing", async () => {
document.body.innerHTML = '<div class="base-author-list"></div>';
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
};
marketRoot.__vue__ = {
_setupState: {
__$temp_1: {
reqParams: {
scene_param: {
platform_source: 1
},
page_param: {
limit: "20",
page: "2"
},
search_param: {
seach_type: 3
}
}
}
}
};
const requestedPages: number[] = [];
const requestedHeaders: Array<HeadersInit | undefined> = [];
const requestedUrls: string[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
const pageNo = Number(body.page_param?.page ?? 0);
requestedPages.push(pageNo);
requestedHeaders.push(init?.headers);
requestedUrls.push(url);
return {
json: async () => ({
authors: [
{
attribute_datas: {
core_user_id: `core-${pageNo}`,
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(
requestedUrls.every((url) =>
url.endsWith("/gw/api/gsearch/search_for_author_square")
)
).toBe(true);
expect(requestedHeaders).toEqual([
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
},
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
}
]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
expect(records?.map((record) => record.coreUserId)).toEqual([
"core-2",
"core-3"
]);
});
test("replays paged exports when the page number is nested inside the request body", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 2
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number };
};
const pageNo = body.page_param?.page ?? 0;
requestedPages.push(pageNo);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("starts from page 1 when the captured request omits an explicit page number", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
filters: {
keyword: "test"
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const page = body.page ?? 0;
requestedPages.push(page);
return {
json: async () => ({
data: {
marketList: [
{
attribute_datas: {
nickname: `达人${page}`,
price_20_60: page * 1000
},
star_id: String(page)
}
]
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([1, 2]);
expect(records?.map((record) => record.authorId)).toEqual(["1", "2"]);
});
test("accepts snapshot headers that contain non-string values from the live XHR capture", async () => {
document.body.innerHTML = "";
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: "1",
limit: "20"
}
}),
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"x-login-source": 1,
"Agw-Js-Conv": "str"
},
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
requestedPages.push(Number(body.page_param?.page ?? 0));
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "达人1"
},
star_id: "1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 1
});
expect(requestedPages).toEqual([1]);
expect(records?.map((record) => record.authorId)).toEqual(["1"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import {
compareExtensionVersions,
parseUpdateManifest
} from "../src/shared/update-check";
describe("update-check", () => {
test("compares dotted extension versions numerically", () => {
expect(compareExtensionVersions("0.2.0421.3", "0.2.0421.2")).toBeGreaterThan(0);
expect(compareExtensionVersions("0.2.10.0", "0.2.9.9")).toBeGreaterThan(0);
expect(compareExtensionVersions("0.2.0421.2", "0.2.0421.2")).toBe(0);
expect(compareExtensionVersions("0.2.0421.1", "0.2.0421.2")).toBeLessThan(0);
});
test("parses a valid update manifest", () => {
expect(
parseUpdateManifest({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
})
).toEqual({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
});
});
test("rejects invalid update manifests", () => {
expect(parseUpdateManifest({ latestVersion: "0.2.0421.3" })).toBeNull();
expect(
parseUpdateManifest({
guideUrl: "javascript:alert(1)",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: [],
zipUrl: "https://cos.example.com/plugin.zip"
})
).toBeNull();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, test } from "vitest";
import { UPDATE_MANIFEST_URL } from "../src/shared/update-config";
describe("update-config", () => {
test("points popup update checks at the COS latest manifest", () => {
expect(UPDATE_MANIFEST_URL).toBe(
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { createLatestManifest } from "../scripts/write-latest-manifest-data.mjs";
describe("write-latest-manifest-data", () => {
test("builds COS asset URLs from the release base", () => {
expect(
createLatestManifest({
latestVersion: "0.2.0421.2",
minSupportedVersion: "0.2.0421.2",
publicBaseUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2",
publishedAt: "2026-05-25"
})
).toEqual({
guideUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/星图增强插件-超简单安装使用指南.pdf",
latestVersion: "0.2.0421.2",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-25",
releaseNotes: [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
zipUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/star-chart-search-enhancer-internal.zip"
});
});
});