77 Commits
Author SHA1 Message Date
wangshaoqing 66e814b30f fix: package release zip without system binary
continuous-integration/drone/tag Build is passing
2026-05-25 11:57:22 +08:00
wangshaoqing d302614b99 feat: automate tag release pipeline
continuous-integration/drone/tag Build is failing
2026-05-25 11:26:02 +08:00
wangshaoqing 57e4dc72aa feat: switch extension updates to COS 2026-05-25 10:32:39 +08:00
wangshaoqing 8fa9fc4469 docs: add COS update design spec 2026-05-25 10:19:14 +08:00
wangshaoqing 02d9063a11 feat: add extension update check 2026-05-19 18:50:03 +08:00
wangshaoqing 703a095c08 chore: hide export range selector 2026-05-19 17:57:09 +08:00
wangshaoqing 75294ca8c7 chore: simplify export action labels 2026-05-19 17:39:57 +08:00
wangshaoqing 303efcdc8f chore: clarify export labels and tooltips 2026-05-19 16:46:49 +08:00
wangshaoqing 50933af0a6 feat: allow selecting audience export fields 2026-05-19 16:26:04 +08:00
wangshaoqing db95a1f565 chore: refresh internal release package 2026-05-19 13:51:27 +08:00
wangshaoqing 37f7e0b5e6 fix: hydrate id export metrics and remove new tier columns 2026-05-18 19:50:14 +08:00
wangshaoqing 38da39589f feat: export audience profiles by author ids 2026-05-18 19:24:15 +08:00
wangshaoqing 39c4191a95 fix: format business ability estimates 2026-05-18 18:40:34 +08:00
wangshaoqing 249e6a5971 feat: export business ability metrics 2026-05-18 18:27:47 +08:00
wangshaoqing ca9ce02db5 fix: match Xingtu profile endpoints 2026-05-18 17:54:37 +08:00
wangshaoqing c8287e8d8e fix: fill missing audience profile buckets 2026-05-18 17:40:21 +08:00
wangshaoqing 26ae3bb4b6 feat: refine audience profile csv columns 2026-05-18 17:25:58 +08:00
wangshaoqing 66bc49d498 feat: add selected audience profile csv export 2026-05-18 16:59:05 +08:00
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
wangshaoqing 3e2d7b36f2 feat: submit batches to status backend 2026-04-23 17:41:44 +08:00
wangshaoqing b3bcc2af45 fix: derive missing A3 batch metrics 2026-04-23 17:41:33 +08:00
wangshaoqing fb45f0cea8 style: match market actions to native primary buttons 2026-04-23 17:00:38 +08:00
wangshaoqing bee8cb0207 feat: refine market action bar and metrics sync 2026-04-23 16:20:14 +08:00
wangshaoqing 24e8a3ba9a docs: add market csv and layout design notes 2026-04-23 14:37:14 +08:00
wangshaoqing 233de28713 fix: stabilize market metrics hydration and sorting 2026-04-23 14:35:14 +08:00
wangshaoqing a51c6f7bf2 feat: add sortable market metric columns 2026-04-23 13:29:20 +08:00
wangshaoqing 2f77199920 Allow partial market rates in CSV export 2026-04-22 19:22:10 +08:00
wangshaoqing c7ae2fbfcb feat: add logto auth and backend metrics integration 2026-04-22 15:52:12 +08:00
wangshaoqing b1bb28f5aa feat: add mock batch submit endpoint 2026-04-22 13:59:34 +08:00
wangshaoqing 766c6a624f feat: wire market batch submission flow 2026-04-22 13:57:24 +08:00
wangshaoqing 3c672f8355 feat: add batch submit toolbar action 2026-04-22 13:54:51 +08:00
wangshaoqing b75755e6a6 feat: add batch submit client 2026-04-22 13:51:39 +08:00
wangshaoqing ff2755e218 feat: add batch payload builder 2026-04-22 13:50:33 +08:00
wangshaoqing 58f5de03f2 docs: add market batch submit design 2026-04-22 13:37:32 +08:00
wangshaoqing 12ff0b56fb docs: add protected api mock verification steps 2026-04-22 10:54:31 +08:00
wangshaoqing 2c7ea3cc45 feat: add mock protected api server 2026-04-22 10:52:57 +08:00
wangshaoqing 668aec45c5 feat: add popup protected api dev test 2026-04-22 10:51:39 +08:00
wangshaoqing cbcc06380d feat: add protected api client 2026-04-22 10:49:28 +08:00
wangshaoqing e8f68e30e5 test: cover auth access token responses 2026-04-22 10:48:12 +08:00
wangshaoqing 18a6a18426 docs: add logto protected api mock design 2026-04-22 10:40:42 +08:00
wangshaoqing 968cc88c62 release: 0.2.0421.2 2026-04-21 17:10:06 +08:00
wangshaoqing 55324a5bb7 feat: stabilize Xingtu market data sync 2026-04-21 14:16:29 +08:00
wangshaoqing 85e835a96a chore: refresh package lock 2026-04-20 20:23:59 +08:00
wangshaoqing b98f812a52 docs: add usage and verification notes 2026-04-20 20:18:36 +08:00
wangshaoqing 9b3c6756bb feat: wire market plugin controls 2026-04-20 20:17:48 +08:00
wangshaoqing f6b2bdc862 feat: add full scan controller 2026-04-20 20:13:29 +08:00
wangshaoqing 86f776ad79 feat: add market dom sync 2026-04-20 20:11:11 +08:00
wangshaoqing c846ab9f4a feat: add csv exporter 2026-04-20 20:09:06 +08:00
wangshaoqing 26bdff8daa feat: add filter and sort controller 2026-04-20 20:07:53 +08:00
wangshaoqing 98dc078a15 feat: add market result store 2026-04-20 20:06:21 +08:00
wangshaoqing 649de1608c feat: add ase api client 2026-04-20 20:05:02 +08:00
wangshaoqing 065b6380bf feat: add rate normalization helpers 2026-04-20 20:01:43 +08:00
wangshaoqing b6b350bbf7 chore: tighten bootstrap test and build config 2026-04-20 19:57:45 +08:00
wangshaoqing 037423853a fix: remove bootstrap content side effects 2026-04-20 19:53:32 +08:00
wangshaoqing 74a2eca0ac chore: bootstrap mv3 extension project 2026-04-20 19:50:05 +08:00
wangshaoqing b04d018840 chore: add project docs baseline 2026-04-20 19:45:00 +08:00
178 changed files with 40010 additions and 6906 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
+31 -4
View File
@@ -1,5 +1,32 @@
node_modules/
.worktrees/
.old-reference/
.local/
dist/
.DS_Store
.vscode/
*.log
# dist-release/
# release/
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
+184 -39
View File
@@ -1,55 +1,200 @@
# Star Chart Search Enhancer
# 星图增强插件
一个最小化的 Chrome MV3 实验插件,用增强巨量星图
这是一个供公司内部使用的 Chrome MV3 插件,用增强巨量星图达人市场页面的使用体验。
- 达人详情页:保留原有的详情页控制台实验链路
- 找达人列表页:在 `creator/market` 当前可见结果页中插入两列看后搜率
主要功能:
## 开发命令
- 在星图达人列表页补充插件侧数据列
- 支持勾选部分达人后导出 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
npm install
npm test
npm run build
npm run build:release
npm run package:internal
```
## 加载插件
---
1. 先执行 `npm run build`
2. 打开 `chrome://extensions`
3. 打开“开发者模式”
4. 选择“加载已解压的扩展程序”
5. 选择本项目的 `dist/` 目录
## 九、维护注意事项
## 手工验证
- 扩展 ID 已通过 `manifest.key` 固定
- 不要泄露本地私钥文件 `.local/extension-key.pem`
- 如果后端地址发生变化,需要同步更新:
- `scripts/manifest.mjs`
- 对应后端配置文件
- 相关文档
### 详情页控制台实验
---
1. 打开巨量星图的达人详情页
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
3. 打开该页面的 DevTools Console
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
## 十、当前状态
### 找达人列表页列增强
当前项目已经支持:
1. 打开 `https://xingtu.cn/ad/creator/market`
2. 等待当前列表页渲染完成
3. 看右侧 sticky 列区,确认 `21-60s报价``操作` 之间新增了两列:
`单视频看后搜率`
`个人视频看后搜率`
4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...`
5. 请求成功后,两列会显示对应达人的真实值
6. 如果某行失败,两列都会显示 `加载失败`
7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...`
8. 如果只看到左侧达人信息区,先把结果区横向滚到最右侧,再检查 `操作` 列前是否已经插入两列
## 当前范围
- 阶段 1 同时支持:
- 巨量星图达人详情页控制台实验
- 巨量星图找达人 `creator/market` 当前可见结果页的两列增强
- 列表页只处理当前可见结果页,不处理全部结果导出
- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果
- 列表页真实结构是右侧 sticky 区域中的“按列渲染” grid,不是传统 `tr/td` 表格;插件按列索引重建每一行并插入两列
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态
- 新固定扩展 ID
- 内部压缩包分发
- 自定义批次名称弹窗
- 后台静默导出
- 批次提交不再由前端生成 `batchId`
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.0525.4",
"host_permissions": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*",
"https://*/*"
]
}
+12
View File
@@ -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.
@@ -1,278 +0,0 @@
# Star Chart After Search Rate 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:** Build a minimal Chrome MV3 extension that captures the two after-search-rate metrics on Xingtu creator detail pages and logs one structured final result per route.
**Architecture:** The extension uses an isolated content script to manage route state, inject a page-context hook, receive `postMessage` results, and print deduplicated structured output. Shared pure utilities handle star ID parsing, route key generation, label normalization, result shaping, and JSON extraction so the network hook stays thin and testable.
**Tech Stack:** TypeScript, Vitest, tsup, Chrome Extension Manifest V3
---
## Implementation Notes
- Current workspace is **not** a git repository, so worktree and commit steps are replaced with explicit verification notes.
- Keep scope to the detail page experiment only.
- Follow TDD strictly: test first, verify red, implement minimal code, verify green.
## Planned File Map
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `vitest.config.ts`
- Create: `scripts/build.mjs`
- Create: `src/manifest.json`
- Create: `src/content/index.ts`
- Create: `src/content/route-state.ts`
- Create: `src/page/hook.ts`
- Create: `src/page/network-interceptor.ts`
- Create: `src/shared/extract-after-search-rates.ts`
- Create: `src/shared/get-star-id.ts`
- Create: `src/shared/message-types.ts`
- Create: `src/shared/normalize-rate-label.ts`
- Create: `src/shared/result-types.ts`
- Create: `src/shared/route-key.ts`
- Create: `tests/extract-after-search-rates.test.ts`
- Create: `tests/get-star-id.test.ts`
- Create: `tests/route-key.test.ts`
- Create: `tests/content-bridge.test.ts`
- Create: `tests/page-hook.test.ts`
- Create: `README.md`
### Task 1: Bootstrap the Tooling and Build Layout
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `vitest.config.ts`
- Create: `scripts/build.mjs`
- Create: `src/manifest.json`
- [ ] **Step 1: Write the failing build-shape test**
Create `tests/build-layout.test.ts` that asserts:
- `src/manifest.json` exists and includes one content script match for `https://*.xingtu.cn/ad/creator/author-homepage/*`
- build script copies manifest and emits `dist/manifest.json`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because project files and scripts do not exist yet
- [ ] **Step 3: Create the minimal tooling files**
Implement:
- `package.json` with `build`, `test`, and `test:run`
- `tsconfig.json`
- `vitest.config.ts`
- `scripts/build.mjs`
- `src/manifest.json`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Verify build output**
Run: `npm run build`
Expected: `dist/manifest.json` exists and TypeScript entrypoints build without errors
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 2: Implement and Test Shared Extraction Utilities
**Files:**
- Create: `src/shared/extract-after-search-rates.ts`
- Create: `src/shared/get-star-id.ts`
- Create: `src/shared/normalize-rate-label.ts`
- Create: `src/shared/result-types.ts`
- Create: `src/shared/route-key.ts`
- Create: `tests/extract-after-search-rates.test.ts`
- Create: `tests/get-star-id.test.ts`
- Create: `tests/route-key.test.ts`
- [ ] **Step 1: Write the failing utility tests**
Add tests covering:
- parsing page star ID from matching and non-matching URLs
- route key creation with incrementing navigation sequence
- exact-key extraction
- label/value extraction with synonymous labels
- text fallback extraction in a bounded subtree
- partial match stays `success: false`
- unrelated payload returns `matched: false`
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: FAIL because utility modules do not exist yet
- [ ] **Step 3: Implement the minimal utilities**
Implement:
- URL parsing for `pageStarId`
- `routeKey` helper
- label normalization helper
- extraction function with `exact-key`, `label-value`, `text-fallback`, and `none`
- shared types for results
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 5: Refactor only if needed**
Keep utility files focused and remove duplication without changing behavior
- [ ] **Step 6: Re-run the utility tests**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 7: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 3: Implement and Test the Content Bridge
**Files:**
- Create: `src/content/index.ts`
- Create: `src/content/route-state.ts`
- Create: `src/shared/message-types.ts`
- Modify: `src/shared/result-types.ts`
- Create: `tests/content-bridge.test.ts`
- [ ] **Step 1: Write the failing content bridge tests**
Add tests covering:
- route state initialization from the current URL
- navigation sequence increment on route changes
- stale route messages are ignored
- duplicate final results are not logged twice
- a later success result can replace an earlier failure result
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/content-bridge.test.ts`
Expected: FAIL because content bridge files do not exist yet
- [ ] **Step 3: Implement the minimal content bridge**
Implement:
- route state tracking
- page-hook injection via `<script src=chrome.runtime.getURL(... )>`
- `window.postMessage` listener with source/type guards
- final structured logging and dedupe policy
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/content-bridge.test.ts`
Expected: PASS
- [ ] **Step 5: Verify route-state behavior stays green**
Run: `npm test -- tests/content-bridge.test.ts tests/route-key.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 4: Implement and Test the Page Hook and Network Interceptor
**Files:**
- Create: `src/page/hook.ts`
- Create: `src/page/network-interceptor.ts`
- Modify: `src/shared/extract-after-search-rates.ts`
- Modify: `src/shared/message-types.ts`
- Create: `tests/page-hook.test.ts`
- [ ] **Step 1: Write the failing page-hook tests**
Add tests covering:
- fetch wrapping reads only `response.clone()`
- original fetch result still resolves unchanged
- hook failures do not block the request
- matching payload posts a structured result message
- timeout produces one failure terminal result
- duplicate patching is prevented by a guard
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/page-hook.test.ts`
Expected: FAIL because page hook files do not exist yet
- [ ] **Step 3: Implement the minimal page hook**
Implement:
- one-time patch guard
- fetch wrapper
- lightweight XHR wrapper
- candidate filtering
- timeout handling
- result posting to the content bridge
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/page-hook.test.ts`
Expected: PASS
- [ ] **Step 5: Run the focused suite**
Run: `npm test -- tests/extract-after-search-rates.test.ts tests/content-bridge.test.ts tests/page-hook.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 5: Finalize Build and Manual Verification Instructions
**Files:**
- Modify: `README.md`
- Modify: `scripts/build.mjs`
- Modify: `src/manifest.json`
- [ ] **Step 1: Write the failing documentation assertion**
Extend `tests/build-layout.test.ts` or add `tests/readme.test.ts` to assert the README documents:
- how to install dependencies
- how to run tests
- how to build
- how to load the unpacked extension
- how to verify the console result on a Xingtu detail page
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because README does not include the required instructions yet
- [ ] **Step 3: Implement the minimal documentation and polish build output**
Add:
- concise README setup and verification instructions
- any build copy adjustments needed for Chrome to load `dist/`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Run the full verification suite**
Run: `npm test`
Expected: all tests PASS
- [ ] **Step 6: Run a fresh production build**
Run: `npm run build`
Expected: build exits 0 and `dist/` contains manifest plus extension scripts
- [ ] **Step 7: Commit**
Skip: repository is not initialized as git; record the verification output instead
@@ -1,450 +0,0 @@
# Star Chart Market Visible Page Columns 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 two after-search-rate columns to the Xingtu creator market list page, auto-load them for the current visible result page, and support cache-backed retryable row rendering without regressing the existing detail-page experiment.
**Architecture:** Keep the existing detail-page hook flow intact, split the content entry into route-aware controllers, and implement the market-page feature entirely inside `src/content/market/`. Market-page data loading uses a dedicated same-origin API client plus a list-sequence-aware controller so DOM sync, cache reuse, retry, and stale-result suppression stay deterministic and testable.
**Tech Stack:** TypeScript, Vitest, jsdom, tsup, Chrome Extension Manifest V3
---
## Implementation Notes
- Current workspace already contains working detail-page code, build scripts, and tests. This plan assumes incremental change, not greenfield scaffolding.
- Current workspace is still not a git repository, so each “commit” step is replaced with a verification note.
- This plan only covers stage 1: current visible result page enhancement.
- Follow TDD strictly: failing test first, then minimal code.
- Do not route the market-page feature through `src/page/hook.ts` unless runtime evidence proves content-script requests are blocked.
- Market async flows must not cache raw `HTMLElement` references across request boundaries. Cache/request layers may store only row metadata such as `authorId`, `listSeq`, and list signature.
- The market API client should expose deterministic error reasons: `timeout` for `AbortError`, `request-failed` for network/non-2xx/parse failures, and `bad-response` for structurally valid responses missing either target field.
- Every async writeback must re-check current DOM markers (`data-sces-author-id`, `data-sces-list-seq`) before rendering, instead of trusting a stale row reference captured earlier.
## Planned File Map
- Modify: `src/manifest.json`
- Add market-page match coverage while preserving detail-page coverage.
- Modify: `src/content/index.ts`
- Reduce to route bootstrap and controller selection.
- Create: `src/content/detail/index.ts`
- Hold the existing detail-page controller logic currently living in `src/content/index.ts`.
- Create: `src/content/market/index.ts`
- Orchestrate market-page DOM sync, list sessions, loading, cache reuse, and retry.
- Create: `src/content/market/api-client.ts`
- Build request URLs, issue same-origin fetches with timeout, and map the known API response into normalized rates.
- Create: `src/content/market/batch-loader.ts`
- Coordinate concurrency-limited loading, stale-result suppression, and row-level retry.
- Create: `src/content/market/cache-store.ts`
- Store success cache and in-flight request dedupe entries by `authorId`.
- Create: `src/content/market/dom-sync.ts`
- Find the target table, insert headers/cells, and bind row metadata markers.
- Create: `src/content/market/id-extractor.ts`
- Extract `authorId` from row links and fallback attributes.
- Create: `src/content/market/list-signature.ts`
- Produce deterministic list/session signatures from current rows and URL state.
- Create: `src/content/market/row-render.ts`
- Render loading, success, and error states into the two injected cells.
- Create: `src/content/market/row-state.ts`
- Define row-status types and transition helpers.
- Create: `src/shared/normalize-rate-value.ts`
- Normalize known rate shapes such as `<0.02%` and `0.02 - 0.1%`.
- Modify: `src/shared/extract-after-search-rates.ts`
- Reuse the extracted shared normalizer so detail-page behavior stays consistent.
- Modify: `tests/build-layout.test.ts`
- Extend manifest/build assertions for the market route.
- Modify: `tests/content-bridge.test.ts`
- Point detail-page tests at the extracted detail controller module.
- Create: `tests/content-entry.test.ts`
- Cover route bootstrap behavior for detail, market, and unsupported URLs.
- Create: `tests/market-api-client.test.ts`
- Cover request URL generation, timeout handling, and response mapping.
- Create: `tests/market-id-extractor.test.ts`
- Cover author-id extraction from row fixtures.
- Create: `tests/market-dom-sync.test.ts`
- Cover header/cell insertion and duplicate protection.
- Create: `tests/market-row-render.test.ts`
- Cover row rendering transitions and retry affordance.
- Create: `tests/market-batch-loader.test.ts`
- Cover cache reuse, in-flight dedupe, concurrency, and retry behavior.
- Create: `tests/market-controller.test.ts`
- Cover list changes, stale-result suppression, and cache-backed rehydration.
- Modify: `tests/readme.test.ts`
- Require README coverage for the market-page feature.
- Modify: `README.md`
- Document market-page support and manual verification.
### Task 1: Split the Content Entry Into a Route Bootstrap and a Detail Controller
**Files:**
- Modify: `src/content/index.ts`
- Create: `src/content/detail/index.ts`
- Modify: `tests/content-bridge.test.ts`
- Create: `tests/content-entry.test.ts`
- [ ] **Step 1: Write the failing route-bootstrap tests**
Add tests asserting:
- detail URLs bootstrap the detail controller
- non-detail, non-market URLs no-op cleanly
- the detail controller still injects the page hook exactly once
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/content-entry.test.ts tests/content-bridge.test.ts`
Expected: FAIL because route bootstrap and extracted detail controller do not exist yet
- [ ] **Step 3: Implement the minimal route bootstrap split**
Implement:
- `createDetailContentController` in `src/content/detail/index.ts`
- route matching in `src/content/index.ts`
- no-op behavior on unsupported URLs
- updated imports in the existing detail tests
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/content-entry.test.ts tests/content-bridge.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record which tests passed and that detail-page behavior remained intact
### Task 2: Extend Manifest Coverage to the Market Page
**Files:**
- Modify: `src/manifest.json`
- Modify: `tests/build-layout.test.ts`
- [ ] **Step 1: Write the failing manifest assertions**
Extend the build-layout test to assert:
- the content script matches both detail and market URLs
- the emitted JS asset list is unchanged
- `web_accessible_resources` for the detail hook still stay present
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because the manifest only targets the detail page
- [ ] **Step 3: Implement the minimal manifest update**
Implement:
- add `https://*.xingtu.cn/ad/creator/market*` to `content_scripts.matches`
- keep existing detail-page matches and page-hook asset coverage
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Verify the build still works**
Run: `npm run build`
Expected: PASS and `dist/manifest.json` includes the market match
- [ ] **Step 6: Record the verification note**
Record the passing build-layout test and build output
### Task 3: Add the Shared Rate Normalizer and the Market API Client
**Files:**
- Create: `src/shared/normalize-rate-value.ts`
- Modify: `src/shared/extract-after-search-rates.ts`
- Create: `src/content/market/api-client.ts`
- Create: `tests/market-api-client.test.ts`
- Modify: `tests/extract-after-search-rates.test.ts`
- [ ] **Step 1: Write the failing API-client and normalization tests**
Cover:
- normalizing `<0.02%` without changing it
- normalizing `0.02 - 0.1%` into `0.02% - 0.1%`
- mapping the known API fields into the two display fields
- returning `bad-response` when either field is missing
- returning `timeout` for aborted requests and `request-failed` for non-OK/network failures
- issuing fetch with `credentials: "include"` and a timeout signal
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/market-api-client.test.ts tests/extract-after-search-rates.test.ts`
Expected: FAIL because the shared normalizer and market API client do not exist yet
- [ ] **Step 3: Implement the minimal shared normalizer and API client**
Implement:
- `normalizeRateValue`
- market request URL builder for `get_author_ase_info`
- response mapper for `avg_search_after_view_rate` and `personal_avg_search_after_view_rate`
- explicit error classification for timeout vs request-failed vs bad-response
- reuse the shared normalizer from the detail extractor
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/market-api-client.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record that both the new market mapper tests and the existing detail extractor tests passed
### Task 4: Add Author-ID Extraction and List-Signature Utilities
**Files:**
- Create: `src/content/market/id-extractor.ts`
- Create: `src/content/market/list-signature.ts`
- Create: `tests/market-id-extractor.test.ts`
- [ ] **Step 1: Write the failing extractor tests**
Cover:
- extracting `authorId` from a row detail link
- extracting from fallback row attributes when available
- returning an explicit missing-id result when no stable source exists
- producing a deterministic list signature from author IDs plus relevant URL state
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-id-extractor.test.ts`
Expected: FAIL because the market row extraction utilities do not exist yet
- [ ] **Step 3: Implement the minimal extractor utilities**
Implement:
- detail-link parsing via `getStarIdFromUrl`
- fallback attribute parsing
- explicit `missing-author-id` results
- deterministic list-signature generation
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-id-extractor.test.ts tests/get-star-id.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing extractor and shared ID tests
### Task 5: Insert the Two Columns and Render Row States
**Files:**
- Create: `src/content/market/dom-sync.ts`
- Create: `src/content/market/row-render.ts`
- Create: `src/content/market/row-state.ts`
- Create: `tests/market-dom-sync.test.ts`
- Create: `tests/market-row-render.test.ts`
- [ ] **Step 1: Write the failing DOM and rendering tests**
Cover:
- inserting two headers before the `操作` column
- inserting two cells before the action cell for each row
- tagging injected cells with stable `data-*` markers
- avoiding duplicate insertion on repeated sync
- rendering `加载中...`, success values, and `加载失败`
- exposing retryable error rows without creating per-cell state divergence
- avoiding any design that requires batch/cache layers to hold row elements after the sync pass ends
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-row-render.test.ts`
Expected: FAIL because the market DOM modules do not exist yet
- [ ] **Step 3: Implement the minimal DOM sync and row rendering**
Implement:
- table/header lookup
- injected header/cell creation
- row-state types
- row rendering with shared row-level status
- ephemeral DOM references only inside the sync/render step
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-row-render.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing DOM and row-render test output
### Task 6: Add Cache, In-Flight Dedupe, Concurrency Control, and Row Retry
**Files:**
- Create: `src/content/market/batch-loader.ts`
- Create: `src/content/market/cache-store.ts`
- Create: `tests/market-batch-loader.test.ts`
- Modify: `src/content/market/api-client.ts`
- Modify: `src/content/market/row-state.ts`
- [ ] **Step 1: Write the failing batch-loader tests**
Cover:
- current page rows auto-enter loading state
- same `authorId` reuses success cache
- same `authorId` in-flight requests are deduplicated
- concurrency cap is honored
- failed rows transition to `加载失败`
- clicking one failed cell retries the whole row
- loader outputs can be dropped safely when the consumer reports a newer `listSeq`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-batch-loader.test.ts`
Expected: FAIL because cache/dedupe/loader behavior does not exist yet
- [ ] **Step 3: Implement the minimal cache and loader pipeline**
Implement:
- in-memory cache entries keyed by `authorId`
- in-flight request reuse
- concurrency-limited scheduling
- row retry behavior
- timeout and request-failed transitions
- result delivery based on row metadata and callbacks, not long-lived DOM node ownership
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-batch-loader.test.ts tests/market-api-client.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing loader and API-client tests
### Task 7: Build the Market Controller and Activate It on the Market Route
**Files:**
- Create: `src/content/market/index.ts`
- Modify: `src/content/index.ts`
- Create: `tests/market-controller.test.ts`
- Modify: `tests/content-entry.test.ts`
- [ ] **Step 1: Write the failing controller tests**
Cover:
- initial market page auto-load for all current rows
- pagination/filter/search/sort DOM changes trigger a fresh sync
- stale async results do not overwrite a newer list
- cached rows rehydrate immediately when they reappear
- the content entry now selects the market controller on market URLs
- writeback only succeeds when fresh DOM markers still match the returning `authorId` and `listSeq`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-controller.test.ts tests/content-entry.test.ts`
Expected: FAIL because the market controller and route activation do not exist yet
- [ ] **Step 3: Implement the minimal market controller**
Implement:
- table observation
- `listSeq` or equivalent sync-session tracking
- fresh sync on list changes
- stale result suppression before DOM writeback
- re-scan current DOM on each sync instead of holding old row node references
- market route activation in `src/content/index.ts`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-controller.test.ts tests/content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Run the focused market suite**
Run: `npm test -- tests/market-api-client.test.ts tests/market-id-extractor.test.ts tests/market-dom-sync.test.ts tests/market-row-render.test.ts tests/market-batch-loader.test.ts tests/market-controller.test.ts`
Expected: PASS
- [ ] **Step 6: Record the verification note**
Record that the market suite and route-entry tests passed
### Task 8: Update README and Run Final Verification
**Files:**
- Modify: `README.md`
- Modify: `tests/readme.test.ts`
- [ ] **Step 1: Write the failing README expectation**
Extend README tests to assert documentation now includes:
- market page enhancement scope
- the two inserted column names
- loading/failure/retry behavior
- how to manually verify on `creator/market`
- the fact that detail-page behavior still exists
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/readme.test.ts`
Expected: FAIL because README does not document market-page behavior yet
- [ ] **Step 3: Update README minimally**
Add:
- market page support notes
- manual verification steps for both detail and market pages
- current stage-1 limitations
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/readme.test.ts`
Expected: PASS
- [ ] **Step 5: Run the full verification suite**
Run: `npm test`
Expected: all tests PASS
- [ ] **Step 6: Run a fresh production build**
Run: `npm run build`
Expected: build exits 0 and `dist/` contains the updated extension assets
- [ ] **Step 7: Record the verification note**
Record the passing full test run and build output
---
## Runtime DOM Note
After testing against the real logged-in `creator/market` page, the market result area was confirmed to be a column-major sticky grid instead of a row-major HTML table:
- outer list root: `.base-author-list`
- header root: `.section-wrapper.sticky-header.hide-scrollbar`
- body root: `.section-wrapper.hide-scrollbar`
- left author area: sticky `content-section` with one `content-column`
- middle metrics: `.middle-columns`
- right sticky area: `content-section` containing multiple `.content-column`s, with the last column being `操作`
This means DOM sync cannot treat each result as a `<tr>` or assume the injected cells live under the same row container. The implementation now reconstructs each logical row by cell index across columns and stamps row metadata onto:
- the author-side row anchor cell
- the injected `单视频看后搜率` cell
- the injected `个人视频看后搜率` cell
## Verification Note
Verified in this workspace after the column-major DOM fix:
- `npm test -- tests/market-dom-sync.test.ts tests/market-controller.test.ts`
- `npm test`
- `npm run build`
All commands exited successfully.
## Runtime Stability Note
After manual browser verification on the real `creator/market` page, an additional runtime issue was identified: refreshing the market page could leave the result area blank or stuck loading.
The market controller was then hardened with three runtime constraints:
- do not start observing the full market DOM while `document.readyState === "loading"`
- do not override `history.pushState` or `history.replaceState` on the market page
- coalesce repeated `MutationObserver` callbacks into one scheduled sync cycle instead of launching unbounded concurrent sync passes during page boot
These constraints are now covered by `tests/market-controller.test.ts` together with the existing market-page behavior checks.
@@ -0,0 +1,853 @@
# Logto Auth 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 Logto-based Chrome extension authentication with a popup login entry, background-owned auth flow, and content-script gating so the Xingtu enhancer is unusable until the user is authenticated.
**Architecture:** Keep Logto-specific behavior inside `src/background/auth/*` and expose a small message contract from `src/shared/auth-messages.ts`. Add a standalone `popup` entry for sign-in / sign-out and dev diagnostics. Gate existing market content initialization behind an auth check so the page only boots the current enhancer after authentication succeeds.
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup build script, `@logto/chrome-extension`
---
## File Map
### New files
- `src/shared/auth-config.ts`
- `src/shared/auth-messages.ts`
- `src/background/auth/types.ts`
- `src/background/auth/state.ts`
- `src/background/auth/client.ts`
- `src/background/auth/controller.ts`
- `src/popup/index.html`
- `src/popup/index.ts`
- `src/popup/view.ts`
- `src/content/market/auth-gate.ts`
- `tests/auth-config.test.ts`
- `tests/auth-messages.test.ts`
- `tests/background-auth-controller.test.ts`
- `tests/popup-entry.test.ts`
- `tests/market-auth-gating.test.ts`
### Existing files to modify
- `package.json`
- `package-lock.json`
- `src/manifest.json`
- `scripts/build.mjs`
- `src/background/index.ts`
- `src/content/index.ts`
- `src/content/market/index.ts`
- `tests/background-index.test.ts`
- `tests/manifest.test.ts`
- `README.md`
- `externaldocs/2026-04-21-logto-auth-design.md`
### Responsibilities
- `src/shared/auth-config.ts`: parse placeholder config, validate required fields, expose a stable config object.
- `src/shared/auth-messages.ts`: define request / response types and type guards for popup-content-background communication.
- `src/background/auth/*`: isolate Logto client creation, auth state mapping, and runtime message handling.
- `src/popup/*`: render login/logout UI and dev diagnostics using background messages only.
- `src/content/market/auth-gate.ts`: render the disabled state and “go login” guidance when auth is missing.
## Task 1: Shared Auth Contract And Config
**Files:**
- Create: `src/shared/auth-config.ts`
- Create: `src/shared/auth-messages.ts`
- Test: `tests/auth-config.test.ts`
- Test: `tests/auth-messages.test.ts`
- Modify: `package.json`
- Modify: `package-lock.json`
- [ ] **Step 1: Add the Logto SDK dependency in the manifest of the repo**
Update `package.json` dependencies to include:
```json
{
"dependencies": {
"@logto/chrome-extension": "^<latest-compatible>"
}
}
```
Run: `npm install`
Expected: `package-lock.json` includes `@logto/chrome-extension`
- [ ] **Step 2: Write the failing config tests**
Create `tests/auth-config.test.ts` with cases for:
```ts
import { describe, expect, test } from "vitest";
import { readAuthConfig } from "../src/shared/auth-config";
describe("auth-config", () => {
test("returns the placeholder config in development", () => {
expect(readAuthConfig()).toEqual({
apiResource: "<your-global-api-resource-indicator>",
appId: "<chrome-extension-app-id>",
enableDevAuthPanel: false,
logtoEndpoint: "https://<your-tenant>.logto.app",
scopes: ["openid", "profile", "offline_access"]
});
});
test("rejects empty endpoint values", () => {
expect(() =>
readAuthConfig({
logtoEndpoint: ""
})
).toThrow(/logtoEndpoint/i);
});
});
```
Run: `npm test -- tests/auth-config.test.ts`
Expected: FAIL because `readAuthConfig` does not exist yet
- [ ] **Step 3: Write the failing message contract tests**
Create `tests/auth-messages.test.ts` with cases for:
```ts
import { describe, expect, test } from "vitest";
import {
isAuthRequestMessage,
isAuthResponseMessage
} from "../src/shared/auth-messages";
describe("auth-messages", () => {
test("accepts a get-state request", () => {
expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true);
});
test("rejects unknown auth requests", () => {
expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false);
});
test("accepts a successful auth response envelope", () => {
expect(
isAuthResponseMessage({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
})
).toBe(true);
});
});
```
Run: `npm test -- tests/auth-messages.test.ts`
Expected: FAIL because helpers and types do not exist yet
- [ ] **Step 4: Implement the minimal shared config**
Create `src/shared/auth-config.ts` with:
```ts
export interface AuthConfig {
apiResource: string;
appId: string;
enableDevAuthPanel: boolean;
logtoEndpoint: string;
scopes: string[];
}
const defaultAuthConfig: AuthConfig = {
apiResource: "<your-global-api-resource-indicator>",
appId: "<chrome-extension-app-id>",
enableDevAuthPanel: false,
logtoEndpoint: "https://<your-tenant>.logto.app",
scopes: ["openid", "profile", "offline_access"]
};
export function readAuthConfig(
overrides: Partial<AuthConfig> = {}
): AuthConfig {
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;
}
```
- [ ] **Step 5: Implement the minimal shared message contract**
Create `src/shared/auth-messages.ts` with:
```ts
export type AuthRequestMessage =
| { type: "auth:get-state" }
| { type: "auth:sign-in" }
| { type: "auth:sign-out" }
| { type: "auth:get-access-token" };
export interface AuthStateValue {
accessTokenExpiresAt?: number | null;
isAuthenticated: boolean;
lastError?: string | null;
resource?: string | null;
scopes?: string[];
tokenAvailable?: boolean;
userInfo?: {
email?: string;
name?: string;
sub?: string;
username?: string;
} | null;
}
export type AuthResponseMessage =
| { ok: true; type: "auth:state"; value: AuthStateValue }
| { ok: true; type: "auth:token"; value: { accessToken: string } }
| { ok: true; type: "auth:ack" }
| { ok: false; type: "auth:error"; error: string };
```
Also add `isAuthRequestMessage()` and `isAuthResponseMessage()` type guards.
- [ ] **Step 6: Run the shared auth tests and verify green**
Run: `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
Expected: PASS
- [ ] **Step 7: Commit the shared contract slice**
Run:
```bash
git add package.json package-lock.json src/shared/auth-config.ts src/shared/auth-messages.ts tests/auth-config.test.ts tests/auth-messages.test.ts
git commit -m "feat: add shared auth config and message contracts"
```
## Task 2: Background Auth Controller
**Files:**
- Create: `src/background/auth/types.ts`
- Create: `src/background/auth/state.ts`
- Create: `src/background/auth/client.ts`
- Create: `src/background/auth/controller.ts`
- Modify: `src/background/index.ts`
- Test: `tests/background-auth-controller.test.ts`
- Modify: `tests/background-index.test.ts`
- [ ] **Step 1: Write the failing background auth controller tests**
Create `tests/background-auth-controller.test.ts` with cases for:
```ts
import { describe, expect, test, vi } from "vitest";
import { createAuthController } from "../src/background/auth/controller";
describe("background-auth-controller", () => {
test("returns unauthenticated state when the client is logged out", async () => {
const controller = createAuthController({
authClient: {
getAccessToken: vi.fn(),
getIdTokenClaims: vi.fn(),
isAuthenticated: vi.fn(async () => false),
signIn: vi.fn(),
signOut: vi.fn()
}
});
await expect(controller.getAuthState()).resolves.toEqual(
expect.objectContaining({
isAuthenticated: false
})
);
});
test("delegates sign in to the auth client", async () => {
const signIn = vi.fn(async () => undefined);
const controller = createAuthController({
authClient: {
getAccessToken: vi.fn(),
getIdTokenClaims: vi.fn(),
isAuthenticated: vi.fn(async () => false),
signIn,
signOut: vi.fn()
}
});
await controller.signIn();
expect(signIn).toHaveBeenCalledTimes(1);
});
});
```
Run: `npm test -- tests/background-auth-controller.test.ts`
Expected: FAIL because the controller does not exist
- [ ] **Step 2: Extend the existing background runtime test with auth messages**
Modify `tests/background-index.test.ts` to add:
```ts
test("responds to auth:get-state with auth status", async () => {
const listeners: Array<any> = [];
const sendResponse = vi.fn();
registerBackgroundMessageHandler(
{
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
},
{
authController: {
getAccessToken: vi.fn(),
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
signIn: vi.fn(),
signOut: vi.fn()
}
}
);
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sendResponse).toHaveBeenCalledWith({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
});
});
```
Run: `npm test -- tests/background-index.test.ts`
Expected: FAIL because `registerBackgroundMessageHandler` only supports CSV download
- [ ] **Step 3: Implement the minimal background auth controller**
Create `src/background/auth/types.ts`:
```ts
export interface AuthClientLike {
getAccessToken(resource?: string): Promise<string>;
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
isAuthenticated(): Promise<boolean>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
```
Create `src/background/auth/state.ts` with a helper that maps client state into the shared `AuthStateValue`.
Create `src/background/auth/controller.ts` with:
```ts
export function createAuthController(options: {
authClient: AuthClientLike;
config?: AuthConfig;
}) {
return {
async getAuthState() {
const isAuthenticated = await options.authClient.isAuthenticated();
if (!isAuthenticated) {
return { isAuthenticated: false, resource: options.config?.apiResource ?? null };
}
const claims = await options.authClient.getIdTokenClaims();
return {
isAuthenticated: true,
resource: options.config?.apiResource ?? null,
scopes: options.config?.scopes ?? [],
userInfo: {
email: typeof claims?.email === "string" ? claims.email : undefined,
name: typeof claims?.name === "string" ? claims.name : undefined,
sub: typeof claims?.sub === "string" ? claims.sub : undefined,
username: typeof claims?.username === "string" ? claims.username : undefined
}
};
},
signIn: () => options.authClient.signIn(),
signOut: () => options.authClient.signOut(),
async getAccessToken() {
return options.authClient.getAccessToken(options.config?.apiResource);
}
};
}
```
- [ ] **Step 4: Add the Logto client factory wrapper**
Create `src/background/auth/client.ts` as the only place that imports `@logto/chrome-extension`:
```ts
import LogtoClient from "@logto/chrome-extension";
import { readAuthConfig } from "../../shared/auth-config";
export function createLogtoAuthClient() {
const config = readAuthConfig();
return new LogtoClient({
appId: config.appId,
endpoint: config.logtoEndpoint,
resources: [config.apiResource],
scopes: config.scopes
});
}
```
- [ ] **Step 5: Integrate auth handling into `src/background/index.ts`**
Refactor `registerBackgroundMessageHandler()` to accept optional dependencies:
```ts
registerBackgroundMessageHandler(chromeLike?, {
authController?: createAuthController(...)
});
```
Handle:
- `auth:get-state` -> `{ ok: true, type: "auth:state", value }`
- `auth:sign-in` -> `{ ok: true, type: "auth:ack" }`
- `auth:sign-out` -> `{ ok: true, type: "auth:ack" }`
- `auth:get-access-token` -> `{ ok: true, type: "auth:token", value: { accessToken } }`
Keep the existing CSV download path intact.
- [ ] **Step 6: Run background tests and verify green**
Run: `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
Expected: PASS
- [ ] **Step 7: Commit the background auth slice**
Run:
```bash
git add src/background/index.ts src/background/auth tests/background-auth-controller.test.ts tests/background-index.test.ts
git commit -m "feat: add background auth controller"
```
## Task 3: Popup Entry, Manifest, And Build Output
**Files:**
- Create: `src/popup/index.html`
- Create: `src/popup/index.ts`
- Create: `src/popup/view.ts`
- Modify: `src/manifest.json`
- Modify: `scripts/build.mjs`
- Modify: `tests/manifest.test.ts`
- Test: `tests/popup-entry.test.ts`
- [ ] **Step 1: Write the failing popup rendering tests**
Create `tests/popup-entry.test.ts` with a JSDOM fixture:
```ts
import { describe, expect, test, vi } from "vitest";
import { bootPopup } from "../src/popup/index";
describe("popup-entry", () => {
test("renders a sign-in button when unauthenticated", async () => {
document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
}))
});
expect(document.querySelector("button")?.textContent).toContain("登录");
});
});
```
Run: `npm test -- tests/popup-entry.test.ts`
Expected: FAIL because popup files do not exist
- [ ] **Step 2: Extend manifest tests for auth permissions and popup**
Modify `tests/manifest.test.ts` to assert:
```ts
expect(manifest.permissions).toEqual(
expect.arrayContaining(["downloads", "identity", "storage"])
);
expect(manifest.action?.default_popup).toBe("popup/index.html");
```
Run: `npm test -- tests/manifest.test.ts`
Expected: FAIL because manifest does not include popup / auth permissions
- [ ] **Step 3: Implement the minimal popup view**
Create `src/popup/view.ts` with rendering helpers:
```ts
export function renderLoggedOut(root: HTMLElement, error?: string | null) {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>登录后才能使用星图增强功能</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button">登录 Logto</button>
</section>
`;
}
```
Also add `renderLoggedIn()` and `renderDevPanel()` helpers.
- [ ] **Step 4: Implement the minimal popup bootstrap**
Create `src/popup/index.ts` with:
```ts
import { renderLoggedIn, renderLoggedOut } from "./view";
export async function bootPopup(options = {}) {
const currentDocument = options.document ?? document;
const root = currentDocument.querySelector("#app") as HTMLElement | null;
if (!root) {
throw new Error("popup root #app is required");
}
const sendMessage =
options.sendMessage ??
((message: unknown) =>
chrome.runtime.sendMessage(message) as Promise<unknown>);
const stateResponse = await sendMessage({ type: "auth:get-state" });
if (!stateResponse.ok || stateResponse.type !== "auth:state") {
renderLoggedOut(root, "认证状态读取失败");
return;
}
if (!stateResponse.value.isAuthenticated) {
renderLoggedOut(root, stateResponse.value.lastError);
root.querySelector("[data-popup-sign-in='button']")?.addEventListener("click", async () => {
await sendMessage({ type: "auth:sign-in" });
});
return;
}
renderLoggedIn(root, stateResponse.value);
}
```
Create `src/popup/index.html`:
```html
<!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>
```
- [ ] **Step 5: Update build and manifest**
Modify `scripts/build.mjs` to:
- build `src/popup/index.ts` into `dist/popup/index.js`
- create `dist/popup`
- copy `src/popup/index.html` into `dist/popup/index.html`
Modify `src/manifest.json` to:
```json
{
"permissions": ["downloads", "identity", "storage"],
"action": {
"default_popup": "popup/index.html"
}
}
```
Preserve current background and content script entries.
- [ ] **Step 6: Run popup and manifest tests**
Run: `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
Expected: PASS
- [ ] **Step 7: Commit the popup and manifest slice**
Run:
```bash
git add src/popup src/manifest.json scripts/build.mjs tests/popup-entry.test.ts tests/manifest.test.ts
git commit -m "feat: add popup auth entry and manifest wiring"
```
## Task 4: Content Auth Gating
**Files:**
- Create: `src/content/market/auth-gate.ts`
- Modify: `src/content/index.ts`
- Modify: `src/content/market/index.ts`
- Test: `tests/market-auth-gating.test.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing content gating test**
Create `tests/market-auth-gating.test.ts`:
```ts
import { describe, expect, test, vi } from "vitest";
import { bootContentScript } from "../src/content/index";
describe("market-auth-gating", () => {
test("shows a login gate instead of booting the market controller when unauthenticated", async () => {
document.body.innerHTML = "<div></div>";
const createMarketController = vi.fn();
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
})),
window
});
expect(createMarketController).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("请先登录插件");
});
});
```
Run: `npm test -- tests/market-auth-gating.test.ts`
Expected: FAIL because `bootContentScript` does not gate on auth yet
- [ ] **Step 2: Add an integration assertion to the existing content entry tests**
Modify `tests/market-content-entry.test.ts` to add:
```ts
test("boots the controller only after auth succeeds", async () => {
const createMarketController = vi.fn(() => ({ ready: Promise.resolve() }));
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window
});
expect(createMarketController).toHaveBeenCalledTimes(1);
});
```
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL until `bootContentScript` accepts the new auth dependency
- [ ] **Step 3: Implement the auth gate UI**
Create `src/content/market/auth-gate.ts`:
```ts
export function renderMarketAuthGate(document: Document): HTMLElement {
const root = document.createElement("section");
root.dataset.marketAuthGate = "root";
root.innerHTML = `
<strong>请先登录插件</strong>
<p>打开扩展弹窗完成登录后刷新本页</p>
<button type="button" data-market-auth-help="button">去登录</button>
`;
root.querySelector("[data-market-auth-help='button']")?.addEventListener("click", () => {
window.alert("请点击浏览器工具栏中的扩展图标完成登录");
});
document.body.prepend(root);
return root;
}
```
- [ ] **Step 4: Gate `bootContentScript()` before controller startup**
Modify `src/content/index.ts` to:
- accept an injectable `sendAuthMessage`
- call background with `{ type: "auth:get-state" }`
- if unauthenticated, render the gate and return `{ ready: Promise.resolve() }` or `null`
- only call `createMarketController()` after auth succeeds
Suggested helper:
```ts
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
const response = await sendMessage({ type: "auth:get-state" });
return response.ok && response.type === "auth:state" ? response.value : null;
}
```
- [ ] **Step 5: Keep market controller auth-agnostic**
Only touch `src/content/market/index.ts` if the existing controller assumes it always owns the toolbar root. Prefer to keep auth gating in `src/content/index.ts`; only add defensive guards if the current toolbar boot sequence conflicts with the auth gate DOM.
- [ ] **Step 6: Run content gating tests**
Run: `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 7: Commit the auth gate slice**
Run:
```bash
git add src/content/index.ts src/content/market/auth-gate.ts src/content/market/index.ts tests/market-auth-gating.test.ts tests/market-content-entry.test.ts
git commit -m "feat: gate market tools behind authentication"
```
## Task 5: Dev Diagnostics, Build Verification, And Docs
**Files:**
- Modify: `src/popup/view.ts`
- Modify: `src/popup/index.ts`
- Modify: `src/background/auth/controller.ts`
- Modify: `README.md`
- Modify: `externaldocs/2026-04-21-logto-auth-design.md`
- [ ] **Step 1: Write the failing popup dev panel test**
Extend `tests/popup-entry.test.ts` with:
```ts
test("renders the dev auth panel when enabled", async () => {
document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
config: { enableDevAuthPanel: true },
document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
accessTokenExpiresAt: 1700000000000,
isAuthenticated: true,
resource: "https://api.example.test",
scopes: ["openid", "profile"],
tokenAvailable: true,
userInfo: { email: "dev@example.com", name: "Dev" }
}
}))
});
expect(document.body.textContent).toContain("resource");
expect(document.body.textContent).toContain("token");
});
```
Run: `npm test -- tests/popup-entry.test.ts`
Expected: FAIL because the dev panel is not rendered yet
- [ ] **Step 2: Implement minimal diagnostics**
Update popup rendering to show when `enableDevAuthPanel` is true:
- `resource`
- `scopes`
- `tokenAvailable`
- `accessTokenExpiresAt`
- `lastError`
Update background state mapping to return `tokenAvailable` and `accessTokenExpiresAt` placeholders even when the exact expiry cannot be resolved yet:
```ts
return {
accessTokenExpiresAt: null,
isAuthenticated: true,
resource: config.apiResource,
scopes: config.scopes,
tokenAvailable: true,
userInfo
};
```
- [ ] **Step 3: Run the popup suite and the full test suite**
Run: `npm test -- tests/popup-entry.test.ts`
Expected: PASS
Run: `npm test`
Expected: PASS across existing and new tests
- [ ] **Step 4: Run the build and verify output structure**
Run: `npm run build`
Expected:
- `dist/manifest.json`
- `dist/background/index.js`
- `dist/content/index.js`
- `dist/content/market-page-bridge.js`
- `dist/popup/index.html`
- `dist/popup/index.js`
- [ ] **Step 5: Update docs**
Update `README.md` with:
- how to load the popup
- what placeholder Logto config values must be replaced
- what “unauthenticated” looks like on the market page
Update `externaldocs/2026-04-21-logto-auth-design.md` only if implementation choices diverge from the design. Also fix any current encoding artifacts in that spec while editing.
- [ ] **Step 6: Commit the verification and docs slice**
Run:
```bash
git add src/popup/view.ts src/popup/index.ts src/background/auth/controller.ts README.md externaldocs/2026-04-21-logto-auth-design.md
git commit -m "docs: document logto auth flow and diagnostics"
```
## Final Verification Checklist
- [ ] `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
- [ ] `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
- [ ] `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
- [ ] `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
- [ ] `npm test`
- [ ] `npm run build`
- [ ] Load `dist/` in `chrome://extensions`
- [ ] Verify popup shows logged-out state before configuration is real
- [ ] Verify market page shows auth gate when unauthenticated
## Open Inputs Still Required From The User
- Real `logtoEndpoint`
- Real `appId`
- Real `apiResource`
- Any extra scopes beyond `openid profile offline_access`
- Whether dev auth panel should be hard-disabled in production builds or controlled by config only
@@ -0,0 +1,217 @@
# Market Export Range 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 configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts.
**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation.
**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script
---
### Task 1: Extend Toolbar Controls For Export Range
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- the toolbar defaults to `前5页`
- selecting `自定义` shows and uses a page-count input
- invalid custom values prevent export
- export mode disables toolbar controls during a running task
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: FAIL because the toolbar has no range selector or custom input behavior.
- [ ] **Step 3: Write the minimal implementation**
Update `plugin-toolbar.ts` to:
- add an export-range `<select>`
- add a custom page-count `<input>`
- add status text support
- expose helpers for reading range state and toggling disabled/running UI
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: add export range toolbar controls"
```
### Task 2: Add Batch Export Coverage
**Files:**
- Modify: `tests/market-content-entry.test.ts`
- Modify: `tests/market-dom-sync.test.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `当前页` exports only the current page
- `前5页` iterates through pages until it reaches page 5 or runs out of pages
- `全部` stops when the next-page control is no longer usable
- repeated authors across pages are merged by `authorId`
- empty fields from later pages do not erase existing populated fields
- page export waits for page signature change before harvesting the next page
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL on the new batch-export expectations.
- [ ] **Step 3: Write the minimal implementation scaffolding**
Only introduce the minimum new helpers/types needed to make the tests compile:
- export range type(s)
- page signature helpers
- harness utilities in tests
- [ ] **Step 4: Run tests to verify the failures are now behavioral**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because batch export logic is still missing, not because types/selectors are missing.
- [ ] **Step 5: Commit**
```bash
git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts
git commit -m "test: cover multi-page export behavior"
```
### Task 3: Implement Batch Export Controller
**Files:**
- Create: `src/content/market/export-range-controller.ts`
- Modify: `src/content/market/types.ts`
- Modify: `src/content/market/dom-sync.ts`
- Modify: `src/content/market/result-store.ts`
- Test: `tests/market-content-entry.test.ts`
- Test: `tests/market-dom-sync.test.ts`
- [ ] **Step 1: Write the failing tests**
Add focused tests for:
- reading a page signature from current DOM
- detecting/using the next-page control
- merging rows by `authorId` with non-empty-field preference
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: FAIL because these helpers/controller do not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Implement `export-range-controller.ts` to:
- normalize export targets (`current`, `count`, `all`)
- harvest current page rows with the existing lazy-load scroll path
- read page signatures
- click/advance pagination and wait for signature change
- merge pages by `authorId` without overwriting non-empty fields
- return a final `MarketRecord[]`
Keep `dom-sync.ts` focused on DOM reads:
- page signature
- next-page element lookup
- next-page enabled check
- [ ] **Step 4: Run tests to verify they pass**
Run:
- `npm test -- tests/market-dom-sync.test.ts`
- `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts
git commit -m "feat: add multi-page export controller"
```
### Task 4: Wire Controller Into Market Entry
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `导出CSV` uses the selected range
- progress text updates while exporting
- controls are re-enabled on success and failure
- invalid custom input blocks export without calling CSV builder
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because `market/index.ts` still always exports current-page records.
- [ ] **Step 3: Write the minimal implementation**
Update `market/index.ts` to:
- parse toolbar export range
- keep current-page export for `当前页`
- delegate batch modes to `export-range-controller.ts`
- update toolbar running/error/progress state
- preserve existing current-page lazy-load harvest path
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: wire export range selection into market export"
```
### Task 5: Verify End-To-End
**Files:**
- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md`
- [ ] **Step 1: Run targeted regression tests**
Run:
- `npm test -- tests/market-content-entry.test.ts`
- `npm test -- tests/market-dom-sync.test.ts`
Expected: PASS
- [ ] **Step 2: Run the full suite**
Run: `npm test`
Expected: PASS with all test files green.
- [ ] **Step 3: Run the build**
Run: `npm run build`
Expected: PASS and emit updated `dist/` bundles.
- [ ] **Step 4: Mark the plan status**
Update this documents checkboxes to reflect completed steps.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-04-21-market-export-range.md
git commit -m "docs: record export range implementation progress"
```
@@ -0,0 +1,710 @@
# Logto Protected API Mock 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 reusable protected-API client that pulls a Logto access token from the extension background, sends `Authorization: Bearer <token>` to a local mock API, and exposes a dev-only popup flow to verify the end-to-end request path before a real backend exists.
**Architecture:** Keep Logto token ownership in the background and add a small reusable client in `src/shared` so both popup and future content code can call protected APIs the same way. Add a tiny local Node mock server that only checks for a Bearer header and returns fixed JSON. Use popup dev UI as the manual test entrypoint because it already has an authenticated extension context and avoids coupling the mock flow to the Xingtu page DOM.
**Tech Stack:** Chrome MV3, TypeScript, Vitest, Node HTTP server, tsup, `@logto/chrome-extension`
---
## File Map
### New files
- `src/shared/protected-api-client.ts`
- `scripts/mock-protected-api.mjs`
- `tests/protected-api-client.test.ts`
- `tests/mock-protected-api.test.ts`
### Existing files to modify
- `src/popup/index.ts`
- `src/popup/view.ts`
- `tests/popup-entry.test.ts`
- `tests/background-index.test.ts`
- `package.json`
- `README.md`
### Responsibilities
- `src/shared/protected-api-client.ts`: reusable client that asks background for a token, injects the Bearer header, and normalizes success / unauthorized / missing-token errors.
- `scripts/mock-protected-api.mjs`: minimal local server with one protected endpoint and a CLI start mode.
- `src/popup/view.ts`: add a dev-only “测试受保护接口” button and a response/error output area.
- `src/popup/index.ts`: wire the dev button to the protected API client and refresh the popup state after calls.
- `tests/protected-api-client.test.ts`: lock down token injection and error behavior.
- `tests/mock-protected-api.test.ts`: verify the mock server authorizes Bearer requests and rejects missing headers.
- `tests/popup-entry.test.ts`: verify popup dev action triggers the protected client and renders results.
## Task 1: Lock Down The Existing Token Message Contract
**Files:**
- Modify: `tests/background-index.test.ts`
- Modify: `src/background/index.ts` only if the test exposes a gap
- [ ] **Step 1: Write the failing background token response test**
Add a case to `tests/background-index.test.ts`:
```ts
test("responds to auth:get-access-token with the current token", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const sendResponse = vi.fn();
registerBackgroundMessageHandler(
{
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
},
{
authController: {
getAccessToken: vi.fn(async () => "test-access-token"),
getAuthState: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn()
}
}
);
const result = listeners[0]({ type: "auth:get-access-token" }, {}, sendResponse);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sendResponse).toHaveBeenCalledWith({
ok: true,
type: "auth:token",
value: { accessToken: "test-access-token" }
});
});
```
- [ ] **Step 2: Run the focused background test**
Run: `npm test -- tests/background-index.test.ts`
Expected:
- If it fails because token handling is broken or missing, continue to Step 3.
- If it passes immediately, keep the test as a regression and skip production changes for this task.
- [ ] **Step 3: Make the minimal background fix if needed**
Ensure `src/background/index.ts` handles:
```ts
if (message.type === "auth:get-access-token") {
return {
ok: true,
type: "auth:token",
value: {
accessToken: await authController.getAccessToken()
}
};
}
```
- [ ] **Step 4: Re-run the background test and confirm green**
Run: `npm test -- tests/background-index.test.ts`
Expected: PASS
- [ ] **Step 5: Commit the token-contract slice**
Run:
```bash
git add tests/background-index.test.ts src/background/index.ts
git commit -m "test: cover auth access token responses"
```
## Task 2: Build The Reusable Protected API Client
**Files:**
- Create: `src/shared/protected-api-client.ts`
- Create: `tests/protected-api-client.test.ts`
- [ ] **Step 1: Write the failing protected client tests**
Create `tests/protected-api-client.test.ts` with cases for:
```ts
import { describe, expect, test, vi } from "vitest";
import { createProtectedApiClient } from "../src/shared/protected-api-client";
describe("protected-api-client", () => {
test("requests a token before calling the protected endpoint", async () => {
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}));
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true })
}));
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
await client.loadProtectedMockData();
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/mock/protected",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer abc123"
}),
method: "GET"
})
);
});
test("throws before fetch when the token is unavailable", async () => {
const sendMessage = vi.fn(async () => ({
ok: false,
type: "auth:error",
error: "token missing"
}));
const fetchImpl = vi.fn();
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
test("throws an authorization error on 401", async () => {
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: false,
status: 401,
json: async () => ({ ok: false, error: "unauthorized" })
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
});
});
```
- [ ] **Step 2: Run the protected client test to verify red**
Run: `npm test -- tests/protected-api-client.test.ts`
Expected: FAIL because `createProtectedApiClient` does not exist yet
- [ ] **Step 3: Implement the minimal protected client**
Create `src/shared/protected-api-client.ts` with:
```ts
import { isAuthResponseMessage } from "./auth-messages";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status: number;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createProtectedApiClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
sendMessage: SendMessageLike;
}) {
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: SendMessageLike): Promise<string> {
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;
}
```
- [ ] **Step 4: Run the protected client test to verify green**
Run: `npm test -- tests/protected-api-client.test.ts`
Expected: PASS
- [ ] **Step 5: Refactor only if tests stay green**
Optional small cleanup only:
- extract URL builder helper
- extract `buildAuthorizationHeaders(token)` helper if repeated
Run: `npm test -- tests/protected-api-client.test.ts`
Expected: PASS
- [ ] **Step 6: Commit the client slice**
Run:
```bash
git add src/shared/protected-api-client.ts tests/protected-api-client.test.ts
git commit -m "feat: add protected api client"
```
## Task 3: Add A Popup Dev Trigger For Manual Verification
**Files:**
- Modify: `src/popup/index.ts`
- Modify: `src/popup/view.ts`
- Modify: `tests/popup-entry.test.ts`
- [ ] **Step 1: Write the failing popup dev-flow tests**
Add cases to `tests/popup-entry.test.ts` for:
```ts
test("renders a protected api test button in the dev panel", async () => {
const { bootPopup } = await import("../src/popup/index");
document.body.innerHTML = '<div id="app"></div>';
await bootPopup({
config: { enableDevAuthPanel: true },
document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
tokenAvailable: true
}
}))
});
expect(document.querySelector('[data-popup-test-protected-api="button"]')).not.toBeNull();
});
test("clicking the dev button runs the protected api client and prints the result", async () => {
const sendMessage = vi.fn(async (message: { type: string }) => {
if (message.type === "auth:get-state") {
return {
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
tokenAvailable: true
}
};
}
if (message.type === "auth:get-access-token") {
return {
ok: true,
type: "auth:token",
value: {
accessToken: "abc123"
}
};
}
return { ok: true, type: "auth:ack" };
});
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true, message: "authorized" })
}));
const { bootPopup } = await import("../src/popup/index");
document.body.innerHTML = '<div id="app"></div>';
await bootPopup({
config: { enableDevAuthPanel: true },
document,
fetchProtectedApi: () =>
createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
}).loadProtectedMockData(),
sendMessage
});
document
.querySelector('[data-popup-test-protected-api="button"]')
?.dispatchEvent(new MouseEvent("click"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.body.textContent).toContain("authorized");
});
```
- [ ] **Step 2: Run the popup tests to verify red**
Run: `npm test -- tests/popup-entry.test.ts`
Expected: FAIL because the popup dev test entrypoint does not exist yet
- [ ] **Step 3: Add the minimal popup UI**
Update `src/popup/view.ts` so `renderDevPanel()` also renders:
```html
<button type="button" data-popup-test-protected-api="button">
测试受保护接口
</button>
<pre data-popup-protected-api-result="output"></pre>
```
Add a small helper for setting the output:
```ts
export function setProtectedApiResult(root: HTMLElement, value: string): void {
root
.querySelector('[data-popup-protected-api-result="output"]')
?.replaceChildren(value);
}
```
- [ ] **Step 4: Wire the popup button to the protected client**
Update `src/popup/index.ts`:
- inject an optional `fetchProtectedApi` dependency
- default it to a `createProtectedApiClient(...)` instance using `sendMessage`
- attach a click handler only when the dev panel is enabled and the user is logged in
- write either the JSON response or the thrown error message into the output area
Suggested shape:
```ts
interface BootPopupOptions {
config?: Partial<AuthConfig>;
document?: Document;
fetchProtectedApi?: () => Promise<unknown>;
sendMessage?: (message: unknown) => Promise<unknown>;
}
```
- [ ] **Step 5: Run the popup tests to verify green**
Run: `npm test -- tests/popup-entry.test.ts`
Expected: PASS
- [ ] **Step 6: Commit the popup dev slice**
Run:
```bash
git add src/popup/index.ts src/popup/view.ts tests/popup-entry.test.ts
git commit -m "feat: add popup protected api dev test"
```
## Task 4: Add The Local Mock Protected API Server
**Files:**
- Create: `scripts/mock-protected-api.mjs`
- Create: `tests/mock-protected-api.test.ts`
- Modify: `package.json`
- [ ] **Step 1: Write the failing mock server tests**
Create `tests/mock-protected-api.test.ts` with:
```ts
import { afterEach, describe, expect, test } from "vitest";
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
const servers: Array<{ close: () => Promise<void> }> = [];
afterEach(async () => {
while (servers.length > 0) {
await servers.pop()?.close();
}
});
describe("mock-protected-api", () => {
test("returns mock data when a Bearer token is present", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
servers.push(server);
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
headers: {
Authorization: "Bearer abc123"
}
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
ok: true,
source: "mock-protected-api"
})
);
});
test("returns 401 when the Authorization header is missing", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
servers.push(server);
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
ok: false,
error: "unauthorized"
});
});
});
```
- [ ] **Step 2: Run the mock server tests to verify red**
Run: `npm test -- tests/mock-protected-api.test.ts`
Expected: FAIL because the mock server module does not exist yet
- [ ] **Step 3: Implement the minimal mock server**
Create `scripts/mock-protected-api.mjs` with:
```js
import http from "node:http";
export function createMockProtectedApiServer({ port = 4319 } = {}) {
let server;
return {
get baseUrl() {
const address = server?.address();
const resolvedPort =
typeof address === "object" && address ? address.port : port;
return `http://127.0.0.1:${resolvedPort}`;
},
async start() {
server = http.createServer((request, response) => {
if (request.url !== "/api/mock/protected") {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "not-found" }));
return;
}
const authHeader = request.headers.authorization ?? "";
const isBearer =
typeof authHeader === "string" &&
authHeader.startsWith("Bearer ") &&
authHeader.length > "Bearer ".length;
if (!isBearer) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
source: "mock-protected-api",
message: "authorized",
receivedAuthHeader: authHeader
})
);
});
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
},
async close() {
if (!server) {
return;
}
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
};
}
if (import.meta.url === `file://${process.argv[1]}`) {
const server = createMockProtectedApiServer();
await server.start();
console.log(`mock protected api listening on ${server.baseUrl}`);
}
```
- [ ] **Step 4: Add a package script to start the mock API**
Update `package.json`:
```json
{
"scripts": {
"mock:protected-api": "node scripts/mock-protected-api.mjs"
}
}
```
- [ ] **Step 5: Run the mock server tests to verify green**
Run: `npm test -- tests/mock-protected-api.test.ts`
Expected: PASS
- [ ] **Step 6: Commit the mock server slice**
Run:
```bash
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts package.json
git commit -m "feat: add mock protected api server"
```
## Task 5: Document And Verify The End-To-End Local Flow
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Write the failing documentation expectation**
Before editing docs, list the missing verification steps in a scratch note:
- how to enable the dev panel
- how to start the mock server
- how to click the popup test button
- what success and failure look like
Expected: current `README.md` does not explain this flow yet
- [ ] **Step 2: Add the manual verification section**
Update `README.md` with a short section:
```md
## 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
5. Open the popup and log in
6. Click `测试受保护接口`
7. Confirm the popup shows a JSON result containing `"source": "mock-protected-api"`
```
- [ ] **Step 3: Run the relevant automated tests together**
Run:
```bash
npm test -- tests/background-index.test.ts tests/protected-api-client.test.ts tests/popup-entry.test.ts tests/mock-protected-api.test.ts
```
Expected: PASS
- [ ] **Step 4: Perform the manual smoke test**
Run in separate terminals:
```bash
npm run mock:protected-api
npm run build
```
Expected:
- terminal 1 prints `mock protected api listening on http://127.0.0.1:4319`
- extension loads from `dist/`
- popup dev panel can trigger the protected endpoint
- success output includes `"message": "authorized"`
- [ ] **Step 5: Commit the docs and verification slice**
Run:
```bash
git add README.md
git commit -m "docs: add protected api mock verification steps"
```
## Final Verification
- [ ] Run the full test suite:
```bash
npm test
```
Expected: PASS
- [ ] Run the production build:
```bash
npm run build
```
Expected: build completes and writes updated assets to `dist/`
- [ ] Confirm the only functional additions are:
- background token response covered by tests
- reusable protected API client
- popup dev test button and result output
- local mock protected API server
- updated verification docs
@@ -0,0 +1,79 @@
# Market Backend Metrics CSV 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:** Extend CSV export so it appends the six backend metrics already stored on each market record.
**Architecture:** Keep the existing export flow intact and modify only the CSV column definition layer. Reuse `MarketRecord.backendMetrics` as the sole source for the six new CSV columns so the exported data matches what the plugin already loaded into memory.
**Tech Stack:** TypeScript, existing market CSV exporter, Vitest
---
## File Map
- Modify: `src/content/market/csv-exporter.ts`
- Append six backend metrics columns after existing CSV columns.
- Modify: `tests/csv-exporter.test.ts`
- Add failing tests for backend metric headers and values.
### Task 1: Backend Metrics CSV Columns
**Files:**
- Modify: `tests/csv-exporter.test.ts`
- Modify: `src/content/market/csv-exporter.ts`
- [ ] **Step 1: Write the failing CSV exporter tests**
Add tests for:
- the six backend metric headers appended after current columns
- backend metric values exported from `record.backendMetrics`
- blank cells when `backendMetrics` is absent
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/csv-exporter.test.ts`
Expected: FAIL because the exporter does not include backend metric columns yet.
- [ ] **Step 3: Write the minimal exporter change**
Append these six columns:
- `看后搜率`
- `看后搜数`
- `新增A3数`
- `新增A3率`
- `CPA3`
- `cp_search`
Each column reads from:
- `record.backendMetrics?.afterViewSearchRate`
- `record.backendMetrics?.afterViewSearchCount`
- `record.backendMetrics?.a3IncreaseCount`
- `record.backendMetrics?.newA3Rate`
- `record.backendMetrics?.cpa3`
- `record.backendMetrics?.cpSearch`
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/csv-exporter.test.ts`
Expected: PASS
- [ ] **Step 5: Run full verification**
Run:
```bash
npm test
npm run build
```
Expected:
- full test suite passes
- build succeeds
- [ ] **Step 6: Commit**
```bash
git add src/content/market/csv-exporter.ts tests/csv-exporter.test.ts docs/superpowers/specs/2026-04-22-market-backend-metrics-csv-design.md docs/superpowers/plans/2026-04-22-market-backend-metrics-csv.md
git commit -m "feat: export backend metrics in csv"
```
@@ -0,0 +1,258 @@
# Market Backend Metrics 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:** Connect the extension to the real backend creator search API and render six backend metrics in a new `秒探指标` column while preserving the two existing Xingtu rate columns.
**Architecture:** Add a background-owned backend metrics request path that uses the existing Logto access token, then batch-request the visible page's `star_id` values from the content layer and map results back onto per-row UI state. Keep Xingtu-sourced rates untouched and add a second metrics structure for the backend-only values.
**Tech Stack:** TypeScript, Chrome MV3 background messaging, existing Logto auth flow, Vitest, tsup
---
## File Map
- Modify: `src/shared/auth-messages.ts`
- Add a background message contract for backend metrics search.
- Create: `src/shared/backend-metrics-config.ts`
- Hold the default backend base URL in one place.
- Create: `src/shared/backend-metrics-client.ts`
- Build the backend request and map response rows into extension-friendly metric objects.
- Modify: `src/background/index.ts`
- Handle the new backend metrics runtime message and call the backend client with Logto token.
- Modify: `src/content/market/types.ts`
- Add types for the six backend metrics and merged row state.
- Modify: `src/content/market/result-store.ts`
- Persist backend metrics, loading, success, missing, and failure states.
- Modify: `src/content/market/dom-sync.ts`
- Add one `秒探指标` column and render loading/success/missing/failure states.
- Modify: `src/content/market/index.ts`
- Batch-load visible page `star_id` values through background and update row state.
- Test: `tests/backend-metrics-client.test.ts`
- Verify request payloads and response mapping.
- Modify: `tests/background-index.test.ts`
- Verify background handles backend metrics requests.
- Modify: `tests/market-content-entry.test.ts`
- Verify the content controller batches visible `star_id` values and renders the new metrics column.
- Modify: `tests/market-dom-sync.test.ts`
- Verify the new UI cell states.
- Modify: `tests/auth-messages.test.ts`
- Verify new message guards.
### Task 1: Shared Backend Metrics Contract
**Files:**
- Create: `src/shared/backend-metrics-config.ts`
- Create: `src/shared/backend-metrics-client.ts`
- Modify: `src/shared/auth-messages.ts`
- Test: `tests/backend-metrics-client.test.ts`
- Test: `tests/auth-messages.test.ts`
- [ ] **Step 1: Write the failing shared tests**
Add tests for:
- default backend base URL export
- request body shape using `type: "star_id"`
- mapping backend fields:
- `avg_after_view_search_rate`
- `avg_after_view_search_cnt`
- `avg_a3_increase_cnt`
- `avg_new_a3_rate`
- `cpa3`
- `cp_search`
- new runtime message guard recognition
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
Expected: FAIL because the backend metrics files and message shapes do not exist yet.
- [ ] **Step 3: Write minimal shared implementation**
Implement:
- `DEFAULT_BACKEND_METRICS_BASE_URL`
- a backend client that posts to `/api/v1/history/talents/search`
- request/response helpers for runtime messaging
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts
git commit -m "feat: add backend metrics shared client"
```
### Task 2: Background Search Bridge
**Files:**
- Modify: `src/background/index.ts`
- Modify: `tests/background-index.test.ts`
- [ ] **Step 1: Write the failing background test**
Add a test for a runtime message like:
```ts
{
type: "backend-metrics:search",
value: {
starIds: ["111", "222"]
}
}
```
Expected behavior:
- background reads the Logto access token
- background calls the backend metrics client
- background returns `{ ok: true, type: "backend-metrics:result", value: ... }`
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/background-index.test.ts`
Expected: FAIL because background does not handle the new message.
- [ ] **Step 3: Write minimal background implementation**
Add:
- a new message type guard branch
- lazy creation of the backend metrics client
- token injection via the existing auth controller
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/background-index.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/background/index.ts tests/background-index.test.ts
git commit -m "feat: wire backend metrics search in background"
```
### Task 3: Row State and DOM Rendering
**Files:**
- Modify: `src/content/market/types.ts`
- Modify: `src/content/market/result-store.ts`
- Modify: `src/content/market/dom-sync.ts`
- Modify: `tests/market-dom-sync.test.ts`
- [ ] **Step 1: Write the failing DOM rendering tests**
Add tests for:
- header contains `秒探指标`
- success state renders six metric labels and values
- loading state renders `加载中...`
- missing state renders `暂无数据`
- failed state renders `加载失败`
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: FAIL because the new column and states are not implemented.
- [ ] **Step 3: Write minimal row-state and rendering implementation**
Add:
- backend metrics types
- store methods for backend metrics status transitions
- DOM helpers to insert and render the compact metrics panel
- [ ] **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/types.ts src/content/market/result-store.ts src/content/market/dom-sync.ts tests/market-dom-sync.test.ts
git commit -m "feat: render backend metrics column"
```
### Task 4: Content Batch Loading
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing content integration tests**
Add tests for:
- collecting visible page `authorId` values as `star_id[]`
- sending one background request for the page instead of one request per row
- mapping result rows by `star_id`
- rendering `暂无数据` when a row is absent from backend response
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because the content controller still uses per-row Xingtu loading only.
- [ ] **Step 3: Write minimal content implementation**
Change the controller so it:
- leaves old Xingtu rates logic intact
- batches backend metrics requests per visible page
- merges backend results into the store by `authorId`
- updates the new metrics cell state
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/index.ts tests/market-content-entry.test.ts
git commit -m "feat: batch load backend metrics for market rows"
```
### Task 5: Full Verification
**Files:**
- Modify only if needed based on failures
- [ ] **Step 1: Run focused backend metrics test suite**
Run:
```bash
npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
```
Expected: PASS
- [ ] **Step 2: Run the full test suite**
Run: `npm test`
Expected: PASS
- [ ] **Step 3: Run production build**
Run: `npm run build`
Expected: PASS and updated assets in `dist/`
- [ ] **Step 4: Manual verification checklist**
Verify in the extension:
- popup login still works
- current page still shows old two Xingtu rate columns
- new `秒探指标` column appears
- rows show loading before data arrives
- success rows show six backend metrics
- unmatched rows show `暂无数据`
- backend/network issues show `加载失败`
- [ ] **Step 5: Final commit**
```bash
git add src/background/index.ts src/content/market/index.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
git commit -m "feat: add backend metrics to market plugin"
```
@@ -0,0 +1,697 @@
# Market Batch Submit 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 standalone `提交批次` toolbar action that collects the currently selected export range of Xingtu creators, prompts for a batch name, builds a batch payload with Logto user identity plus creator IDs, and submits the batch to a protected API endpoint with a Bearer token.
**Architecture:** Reuse the existing export-range collection path so multi-page batch submission behaves exactly like CSV export. Keep responsibilities split: toolbar UI emits a submit intent, a batch-payload module assembles the request body, and a protected batch-submit client owns token injection plus POST behavior. For first-pass verification, extend the local mock server with a batch endpoint that echoes the submitted payload.
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup, Node HTTP server, `@logto/chrome-extension`
---
## File Map
### New files
- `src/content/market/batch-payload.ts`
- `src/shared/batch-submit-client.ts`
- `tests/batch-payload.test.ts`
- `tests/batch-submit-client.test.ts`
### Existing files to modify
- `src/content/market/plugin-toolbar.ts`
- `src/content/market/index.ts`
- `src/shared/auth-messages.ts`
- `src/background/auth/state.ts`
- `src/popup/view.ts`
- `scripts/mock-protected-api.mjs`
- `tests/market-content-entry.test.ts`
- `tests/popup-entry.test.ts`
- `tests/mock-protected-api.test.ts`
- `README.md`
### Responsibilities
- `src/content/market/plugin-toolbar.ts`: add a dedicated submit button and include it in busy-state handling.
- `src/content/market/index.ts`: orchestrate prompt input, range collection reuse, payload creation, and batch submission.
- `src/content/market/batch-payload.ts`: create the batch payload from batch name, timestamp, auth state, and market records.
- `src/shared/batch-submit-client.ts`: POST JSON to the protected batch endpoint with `Authorization: Bearer <token>`.
- `src/shared/auth-messages.ts` and `src/background/auth/state.ts`: expose stable access to Logto user `sub` and display name already present in auth state.
- `scripts/mock-protected-api.mjs`: add a local `/api/mock/batches` endpoint that validates Bearer auth and echoes the payload.
## Task 1: Add The Batch Payload Builder
**Files:**
- Create: `src/content/market/batch-payload.ts`
- Create: `tests/batch-payload.test.ts`
- [ ] **Step 1: Write the failing batch payload tests**
Create `tests/batch-payload.test.ts` with cases for:
```ts
import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload";
describe("batch-payload", () => {
test("builds a batch id from the batch name and timestamp", () => {
const payload = createBatchPayload({
authState: {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: {
name: "王少卿",
sub: "p7pdhhtde8kj"
}
},
batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z",
records: [
{ authorId: "111", authorName: "达人A", status: "success" },
{ authorId: "222", authorName: "达人B", status: "success" }
]
});
expect(payload).toEqual({
authors: [
{ authorId: "111", authorName: "达人A" },
{ authorId: "222", authorName: "达人B" }
],
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
});
});
test("throws when the user id is unavailable", () => {
expect(() =>
createBatchPayload({
authState: {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: {
name: "王少卿"
}
},
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
})
).toThrow(/user/i);
});
});
```
- [ ] **Step 2: Run the batch payload test to verify red**
Run: `npm test -- tests/batch-payload.test.ts`
Expected: FAIL because `createBatchPayload` does not exist yet
- [ ] **Step 3: Implement the minimal batch payload builder**
Create `src/content/market/batch-payload.ts` with:
```ts
import type { AuthStateValue } from "../../shared/auth-messages";
import type { MarketRecord } from "./types";
export interface BatchPayload {
authors: Array<{
authorId: string;
authorName: string;
}>;
batchId: string;
batchName: string;
createdAt: string;
creatorName: string;
logtoUserId: string;
resource: string;
}
export function createBatchPayload(options: {
authState: AuthStateValue;
batchName: string;
createdAt: string;
records: MarketRecord[];
}): BatchPayload {
const logtoUserId = options.authState.userInfo?.sub?.trim();
if (!logtoUserId) {
throw new Error("batch submit user id unavailable");
}
const resource = options.authState.resource?.trim();
if (!resource) {
throw new Error("batch submit resource unavailable");
}
const batchName = options.batchName.trim();
if (!batchName) {
throw new Error("batch submit batch name is required");
}
return {
authors: options.records.map((record) => ({
authorId: record.authorId,
authorName: record.authorName
})),
batchId: `${batchName}-${options.createdAt}`,
batchName,
createdAt: options.createdAt,
creatorName:
options.authState.userInfo?.name ??
options.authState.userInfo?.username ??
logtoUserId,
logtoUserId,
resource
};
}
```
- [ ] **Step 4: Run the batch payload test to verify green**
Run: `npm test -- tests/batch-payload.test.ts`
Expected: PASS
- [ ] **Step 5: Commit the payload builder slice**
Run:
```bash
git add src/content/market/batch-payload.ts tests/batch-payload.test.ts
git commit -m "feat: add batch payload builder"
```
## Task 2: Build The Protected Batch Submit Client
**Files:**
- Create: `src/shared/batch-submit-client.ts`
- Create: `tests/batch-submit-client.test.ts`
- [ ] **Step 1: Write the failing batch submit client tests**
Create `tests/batch-submit-client.test.ts` with:
```ts
import { describe, expect, test, vi } from "vitest";
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => {
test("posts the batch payload with a Bearer token", async () => {
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}));
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true, acceptedCount: 2 })
}));
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
await client.submitBatch({
authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
});
expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/mock/batches",
expect.objectContaining({
body: JSON.stringify(
expect.objectContaining({
batchId: "批次A-2026-04-22T12:30:00.000Z"
})
),
headers: expect.objectContaining({
Authorization: "Bearer abc123",
"Content-Type": "application/json"
}),
method: "POST"
})
);
});
test("throws on unauthorized responses", async () => {
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: false,
status: 401,
json: async () => ({ ok: false, error: "unauthorized" })
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(
client.submitBatch({
authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
})
).rejects.toThrow(/unauthorized/i);
});
});
```
- [ ] **Step 2: Run the batch submit client test to verify red**
Run: `npm test -- tests/batch-submit-client.test.ts`
Expected: FAIL because `createBatchSubmitClient` does not exist yet
- [ ] **Step 3: Implement the minimal batch submit client**
Create `src/shared/batch-submit-client.ts` with:
```ts
import { isAuthResponseMessage } from "./auth-messages";
import type { BatchPayload } from "../content/market/batch-payload";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status: number;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
sendMessage: SendMessageLike;
}) {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async submitBatch(payload: BatchPayload) {
const token = await readAccessToken(options.sendMessage);
const response = await fetchImpl(
new URL("/api/mock/batches", options.baseUrl).toString(),
{
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
method: "POST"
}
);
if (response.status === 401 || response.status === 403) {
throw new Error("batch submit unauthorized");
}
if (!response.ok) {
throw new Error(`batch submit failed: ${response.status}`);
}
return response.json();
}
};
}
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
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("batch submit token unavailable");
}
return response.value.accessToken;
}
```
- [ ] **Step 4: Run the batch submit client test to verify green**
Run: `npm test -- tests/batch-submit-client.test.ts`
Expected: PASS
- [ ] **Step 5: Commit the batch submit client slice**
Run:
```bash
git add src/shared/batch-submit-client.ts tests/batch-submit-client.test.ts
git commit -m "feat: add batch submit client"
```
## Task 3: Extend The Toolbar With A Batch Submit Action
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing toolbar tests**
Add tests to `tests/market-content-entry.test.ts` for:
```ts
test("renders a batch submit button in the toolbar", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
document,
loadAuthorMetrics: async () => ({
success: true,
rates: {
personalVideoAfterSearchRate: "0.01% - 0.1%",
singleVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
window
});
await controller.ready;
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
});
```
Also add a test that `setToolbarBusyState()` disables the batch submit button.
- [ ] **Step 2: Run the focused market toolbar tests to verify red**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because the batch submit button does not exist yet
- [ ] **Step 3: Add the minimal toolbar button**
Update `src/content/market/plugin-toolbar.ts`:
- extend `PluginToolbarHandlers` with `onSubmitBatch()`
- extend `PluginToolbarDom` with `batchSubmitButton: HTMLButtonElement`
- create a new button:
```ts
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
```
- append it next to `exportButton`
- wire its click handler to `handlers.onSubmitBatch()`
- include it in `readToolbarDom()` and `setToolbarBusyState()`
- [ ] **Step 4: Re-run the focused market toolbar tests to verify green**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit the toolbar slice**
Run:
```bash
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: add batch submit toolbar action"
```
## Task 4: Wire Batch Submission Into The Market Controller
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `src/shared/auth-messages.ts`
- Modify: `src/background/auth/state.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing controller tests**
Add tests to `tests/market-content-entry.test.ts` for:
```ts
test("prompts for a batch name before submitting the current range", async () => {
document.body.innerHTML = buildMarketFixture();
const prompt = vi.fn(() => "618达人筛选第一批");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: true,
rates: {
personalVideoAfterSearchRate: "0.01% - 0.1%",
singleVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
promptBatchName: prompt,
submitBatch,
window
});
await controller.ready;
(document.querySelector('[data-plugin-batch-submit="button"]') as HTMLButtonElement).click();
await Promise.resolve();
expect(prompt).toHaveBeenCalled();
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
batchName: "618达人筛选第一批",
logtoUserId: "p7pdhhtde8kj"
})
);
});
test("shows an error when the batch name is blank", async () => {
// same setup, but prompt returns " "
// expect toolbar status to contain "请输入批次名称"
});
test("does nothing when the prompt is cancelled", async () => {
// prompt returns null
// submitBatch is not called
});
```
- [ ] **Step 2: Run the focused market controller tests to verify red**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because batch submission wiring does not exist yet
- [ ] **Step 3: Add the minimal market controller wiring**
Update `src/content/market/index.ts`:
- add optional dependencies:
```ts
getAuthState?: () => Promise<AuthStateValue>;
promptBatchName?: () => string | null;
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
```
- default `getAuthState` to a `chrome.runtime.sendMessage({ type: "auth:get-state" })` wrapper
- default `promptBatchName` to `() => window.prompt("请输入批次名称")`
- default `submitBatch` to `createBatchSubmitClient(...).submitBatch`
- add `onSubmitBatch` handler in `ensurePluginToolbar(...)`
- behavior:
- read target via `readToolbarExportTarget()`
- prompt for batch name
- cancel on `null`
- reject blank names with `setToolbarExportStatus(toolbar, "请输入批次名称")`
- collect records via existing `exportRecords(target)`
- read auth state
- build payload with `createBatchPayload(...)`
- call `submitBatch(payload)`
- show `批次提交成功` on success
- show thrown message or `批次提交失败,请稍后重试` on failure
- [ ] **Step 4: Extend auth state only if the tests expose a gap**
If tests reveal missing fields, ensure `src/background/auth/state.ts` continues exposing:
- `userInfo.sub`
- `userInfo.name`
- `resource`
If `src/shared/auth-messages.ts` needs typing support, update only types, not message kinds.
- [ ] **Step 5: Re-run the focused market controller tests to verify green**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 6: Commit the market controller slice**
Run:
```bash
git add src/content/market/index.ts src/shared/auth-messages.ts src/background/auth/state.ts tests/market-content-entry.test.ts
git commit -m "feat: wire market batch submission flow"
```
## Task 5: Extend The Mock Server And Popup Debugging
**Files:**
- Modify: `scripts/mock-protected-api.mjs`
- Modify: `tests/mock-protected-api.test.ts`
- Modify: `src/popup/view.ts`
- Modify: `tests/popup-entry.test.ts` only if needed for debug output wording
- Modify: `README.md`
- [ ] **Step 1: Write the failing mock batch endpoint tests**
Add cases to `tests/mock-protected-api.test.ts`:
```ts
test("accepts a batch payload when a Bearer token is present", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
}),
headers: {
Authorization: "Bearer abc123",
"Content-Type": "application/json"
},
method: "POST"
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
acceptedCount: 1,
ok: true,
source: "mock-batch-submit"
})
);
});
```
- [ ] **Step 2: Run the mock server tests to verify red**
Run: `npm test -- tests/mock-protected-api.test.ts`
Expected: FAIL because the batch endpoint does not exist yet
- [ ] **Step 3: Implement the minimal mock batch endpoint**
Update `scripts/mock-protected-api.mjs`:
- keep the existing `/api/mock/protected`
- add `/api/mock/batches` for `POST`
- require the same Bearer header validation
- read the JSON body and return:
```json
{
"ok": true,
"source": "mock-batch-submit",
"acceptedCount": 1,
"batchId": "<echoed batch id>"
}
```
- [ ] **Step 4: Update the docs**
Add to `README.md`:
- how to trigger `提交批次`
- how the prompt should be filled
- expected mock response
- [ ] **Step 5: Re-run the mock server tests to verify green**
Run: `npm test -- tests/mock-protected-api.test.ts`
Expected: PASS
- [ ] **Step 6: Commit the mock batch slice**
Run:
```bash
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts README.md
git commit -m "feat: add mock batch submit endpoint"
```
## Final Verification
- [ ] Run the focused batch feature tests:
```bash
npm test -- tests/auth-config.test.ts tests/batch-payload.test.ts tests/batch-submit-client.test.ts tests/mock-protected-api.test.ts tests/market-content-entry.test.ts
```
Expected: PASS
- [ ] Run the full test suite:
```bash
npm test
```
Expected: PASS
- [ ] Run the production build:
```bash
npm run build
```
Expected: build completes and updates `dist/`
- [ ] Perform the manual smoke test:
1. Start `npm run mock:protected-api`
2. Reload the unpacked extension from `dist/`
3. Log in through the popup
4. Open the Xingtu market page
5. Choose an export range
6. Click `提交批次`
7. Enter a batch name in `prompt()`
8. Confirm success text appears
9. Verify the mock response contains the batch id and accepted creator count
@@ -0,0 +1,61 @@
# Market Scrollable Plugin Columns 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:** Move plugin-generated market columns out of the right sticky area and into the horizontally scrollable middle area.
**Architecture:** Keep Xingtu's native left and right sticky sections intact. Add a dedicated non-sticky plugin section for the injected columns and continue rendering row state through the existing `MarketRowDom` abstraction.
**Tech Stack:** TypeScript, Vitest, jsdom
---
### Task 1: Lock The Intended Layout In Tests
**Files:**
- Modify: `tests/market-dom-sync.test.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write failing tests for the layout boundary**
Add assertions that:
- the right sticky header/body widths remain native after plugin columns are added
- plugin columns live in a dedicated non-sticky section instead of the right sticky section
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
- [ ] **Step 3: Implement the minimal layout changes**
Update the div-grid sync path so plugin columns are inserted into a separate scrollable section.
- [ ] **Step 4: Re-run the same tests**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
- [ ] **Step 5: Commit**
```bash
git add tests/market-dom-sync.test.ts tests/market-content-entry.test.ts src/content/market/dom-sync.ts
git commit -m "feat: move market plugin columns into scrollable section"
```
### Task 2: Verify No Export Regression
**Files:**
- Verify only: `src/content/market/index.ts`
- Verify only: `src/content/market/export-range-controller.ts`
- Verify only: `tests/full-scan-controller.test.ts`
- [ ] **Step 1: Run export-related regression tests**
Run: `npm test -- tests/full-scan-controller.test.ts`
- [ ] **Step 2: Run build**
Run: `npm run build`
- [ ] **Step 3: Report any unrelated failures separately**
Do not broaden the scope unless a new failure is caused by the layout change.
@@ -0,0 +1,161 @@
# Market Native Action Bar 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:** Move the plugin export actions into Xingtu's native market action row and remove the old top toolbar and unused filter/sort controls.
**Architecture:** Keep the existing export and batch-submit business logic, but replace the toolbar mount point and DOM shape. The new toolbar becomes a small inline action bar inserted into the native button row beside `自定义指标` and `导出`, while the controller only depends on export-range and action buttons.
**Tech Stack:** TypeScript, Chrome MV3 content script, jsdom/Vitest
---
## File Map
- Modify: `src/content/market/plugin-toolbar.ts`
- Replace the top toolbar with an inline native-style action bar.
- Modify: `src/content/market/index.ts`
- Remove filter/sort toolbar dependencies.
- Modify: `tests/market-content-entry.test.ts`
- Update toolbar assertions to the new placement and reduced control set.
### Task 1: Lock The New Toolbar Shape In Tests
**Files:**
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing toolbar placement tests**
Add tests that assert:
- the plugin toolbar is inserted next to the native `自定义指标` / `导出` action row
- the toolbar no longer renders filter/sort controls
- the toolbar still renders export range, custom page input, export button, batch button, and status text
- [ ] **Step 2: Run the focused test to verify it fails**
Run: `npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row"`
Expected: FAIL because the toolbar still prepends to `document.body` and still contains filter/sort controls.
- [ ] **Step 3: Add a failing busy-state regression test**
Assert that during export:
- export button is disabled
- batch button is disabled
- export range select is disabled
- custom page input is disabled when visible
- [ ] **Step 4: Run the focused busy-state test**
Run: `npx vitest run tests/market-content-entry.test.ts -t "exporting all pages disables the native action bar controls during the task"`
Expected: FAIL only if the new toolbar structure breaks existing busy-state selectors.
### Task 2: Implement The Native Action Bar
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- [ ] **Step 1: Replace the toolbar mount strategy**
Implement a helper that finds the native action row containing `自定义指标` and `导出`, then inserts the plugin root into that row.
- [ ] **Step 2: Replace the toolbar DOM structure**
Create only:
- export range select
- custom pages input
- export button
- batch submit button
- export status text
Remove creation and lookup of:
- filter inputs
- filter button
- sort field select
- sort direction select
- sort button
- [ ] **Step 3: Apply native-style button and inline layout styling**
Use lightweight inline styles / class reuse so the plugin controls visually align with the native button row.
- [ ] **Step 4: Keep custom-range visibility logic working**
Preserve:
- `current`
- `first-5`
- `first-10`
- `all`
- `custom`
When `custom` is selected, show the input; otherwise hide it.
- [ ] **Step 5: Run the focused tests to verify green**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row|exporting all pages disables the native action bar controls during the task"
```
Expected: PASS
### Task 3: Remove Toolbar Filter/Sort Dependencies
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Remove toolbar filter/sort read paths**
Delete the toolbar handlers and state reads that depend on removed controls.
- [ ] **Step 2: Keep export and batch submission behavior intact**
Ensure:
- export still reads the selected range
- batch submit still reads the selected range
- status text still updates during progress and completion
- [ ] **Step 3: Update affected tests**
Adjust tests that previously asserted filter/sort button disabled state so they assert only the remaining controls.
- [ ] **Step 4: Run focused regression tests**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts -t "custom export range blocks invalid page counts|prompts for a batch name before submitting the current range|exporting all pages disables the native action bar controls during the task"
```
Expected: PASS
### Task 4: Final Verification
**Files:**
- Verify only: `src/content/market/plugin-toolbar.ts`
- Verify only: `src/content/market/index.ts`
- Verify only: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Run the targeted market content tests**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts
```
- [ ] **Step 2: Run build**
Run:
```bash
npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/content/market/plugin-toolbar.ts src/content/market/index.ts tests/market-content-entry.test.ts docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md docs/superpowers/plans/2026-04-23-market-native-action-bar.md
git commit -m "feat: move market actions into native action bar"
```
@@ -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.
@@ -1,568 +0,0 @@
# 星图达人详情页看后搜率插件实验设计
## 1. 背景
目标站点为巨量星图达人详情页。当前已经人工确认:
- 达人详情页右侧可以看到两个“看后搜率”指标
- 页面 URL 中可观察到稳定的星图达人 ID
- `search_session_id` 在短时间实验中看起来可能不变,但不应作为稳定依赖
本次工作不是直接做完整产品,而是做一个最小可验证实验,确认浏览器插件能否自动获取这两个指标。
## 2. 目标
本实验的目标只有一个:
- 在巨量星图达人详情页内,由 Chrome 插件自动拿到两个看后搜率,并以结构化结果输出到控制台或插件侧日志中
成功标准:
- 进入达人详情页后,插件无需人工复制数据
- 插件能自动拿到两个目标值
- 输出结果中包含达人标识、页面 URL、命中的请求 URL、两个看后搜率值
- 当两项值都提取成功时,明确标记成功
- 插件注入与拦截不能影响页面原有请求、渲染与交互
- 同一次详情页进入只输出一个最终结果;如果先失败后成功,可以升级为成功结果
## 3. 非目标
本实验明确不做以下内容:
- 不在找达人列表页批量抓取多个达人数据
- 不在列表页直接渲染看后搜率列
- 不复现页面接口签名或自行构造后台请求
- 不依赖 `search_session_id`
- 不做插件 UI 美化
- 不做导出、缓存、排序、批处理
- 不接入后端服务或数据库
## 4. 关键判断
### 4.1 达人标识
实验阶段将 URL 中的星图达人 ID 作为当前页面达人标识候选值。插件应优先从详情页路径中提取该 ID,并在日志输出中保留。
### 4.2 会话参数
`search_session_id` 不参与任何业务判断。它更像搜索会话或埋点参数,可能随着入口、刷新、筛选条件或路由变化而变化,不应作为稳定主键。
### 4.3 数据来源策略
本实验接受复用页面自己发出的请求来拿数据,因此优先方案为:
- 在页面上下文中拦截 `fetch` / `XMLHttpRequest`
- 解析页面真实收到的接口响应
- 从响应中提取两个看后搜率
这比插件自行复刻请求更适合当前最小实验,因为它不需要单独处理 CORS、Cookie、鉴权、签名和接口重放。
### 4.4 结果解释边界
如果插件在观察窗口内始终没有拿到完整结果,这只能说明“当前网络拦截路径尚未被证实可行”,不能直接推断为插件实现失败。
也就是说:
- 成功命中时,可以证明“网络拦截方案可行”
- 持续超时或只拿到无关响应时,只能证明“本轮实验未证实该路径可行”
- 若连续多次超时,应转向检查 DOM、内联 bootstrap 数据或更早期的数据注入方式,而不是继续盲目扩展字段匹配规则
## 5. 方案概览
插件采用 Chrome Manifest V3,先只支持达人详情页。
核心由两层脚本组成:
- `content script`
- 负责在详情页尽早注入页面脚本
- 负责接收页面脚本传回的数据
- 负责统一打印结构化结果
- `page hook script`
- 运行在页面上下文
- 包装 `window.fetch``XMLHttpRequest`
- 拦截和分析候选响应
- 一旦提取到两个看后搜率,通过 `window.postMessage` 发回内容脚本
首版默认不引入以下能力,避免实验面过大:
- 不使用 `background service worker`
- 不申请 `storage``tabs``scripting` 等额外权限,除非实现阶段确认必需
- 不做 popup、options page 或独立调试面板
## 6. 页面范围与前提
### 6.1 页面匹配范围
首版只匹配达人详情页,例如:
- `https://*.xingtu.cn/ad/creator/author-homepage/*`
Manifest 约束建议同时写清:
- `content_scripts.matches` 只覆盖上述详情页
- `run_at` 使用 `document_start`
- 注入到页面上下文的脚本通过 `chrome.runtime.getURL(...)` + `<script>` 标签加载
- 被注入的页面脚本必须放入 `web_accessible_resources`
如果实现阶段发现页面 CSP 会拦截上述脚本标签注入,则需要切换为单一路径兜底,而不是同时堆叠多套方案:
- 优先评估 `content_scripts.world = "MAIN"` 是否足够
- 若仍不足,再评估引入最小 `background` + `chrome.scripting` 路径
- 首版不应在尚未证实必要性时提前引入两套注入机制
### 6.2 前提假设
- 目标值来源于页面请求返回的数据,而不是纯 DOM 计算结果
- 页面在进入详情页时会触发至少一个包含目标数据的 JSON 请求
- 为提高命中率,实验允许用户在页面打开后刷新一次
## 7. 架构与数据流
整体数据流如下:
1. 用户进入达人详情页
2. `content script``document_start` 注入 `page hook script`
3. `page hook script` 拦截后续 `fetch` / `XHR`
4. 过滤候选响应并尝试解析 JSON
5. 从响应中提取两个看后搜率
6. 通过 `window.postMessage` 将结果发回 `content script`
7. `content script` 记录结构化日志,作为实验成功依据
建议统一输出如下结构:
```js
{
success: true,
stage: "captured",
routeKey: "6629661559960371207::/ad/creator/author-homepage/6629661559960371207::1",
pageStarId: "6629661559960371207",
responseStarId: "6629661559960371207",
pageUrl: "https://xingtu.cn/ad/creator/author-homepage/...",
matchedRequestUrl: "https://...",
requestMethod: "GET",
status: 200,
extractorLevel: "label-value",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.5% - 1%"
},
rawPathHints: [
"data.xxx.card_list[2].metrics[0]",
"data.xxx.card_list[2].metrics[1]"
],
source: "network",
capturedAt: 1776210000000
}
```
当无法完整提取时,`success` 必须为 `false`,并附带错误阶段信息。无论成功还是失败,最终结果结构都应稳定包含:
- `success`
- `stage`
- `routeKey`
- `pageStarId`
- `pageUrl`
- `capturedAt`
- `reason``rates`
## 8. 网络拦截策略
### 8.1 拦截范围
页面脚本同时拦截:
- `window.fetch`
- `XMLHttpRequest.prototype.open`
- `XMLHttpRequest.prototype.send`
原因:
- 站点可能混用 `fetch``XHR`
- 实验阶段不应预设调用方式
### 8.2 拦截实现约束
无论采用什么包装方式,都必须满足以下不破坏页面的约束:
- 包装 `fetch` / `XHR` 时不得改变原始返回值、异常行为或 `this` 绑定
- 读取 `fetch` 响应时只能消费 `response.clone()`,绝不能读取原始 body
- `XHR` 只在请求完成后读取 `response` / `responseText`,不得篡改业务侧回调链
-`blob``arraybuffer``document` 等非文本响应直接跳过
- 所有 hook 逻辑必须 `fail-open`:自身异常只能吞掉并记录 `debug` 日志,不能阻断页面请求
- 页面脚本要有一次性 patch guard,避免重复注入导致多层包装
### 8.3 候选响应筛选
不是所有响应都值得解析。为减少噪声,先做轻量筛选:
- 响应 `content-type` 包含 `json`
- 请求 URL 包含达人详情、商业能力、种草价值、author-homepage、creator 等相关片段时优先
- 非 JSON 响应直接跳过
- 若响应头缺失 `content-type`,但 URL 命中高相关片段且响应文本形态像 JSON,可做一次受控解析尝试
- 为控制成本,可给响应文本设置大小上限;超限时只记录摘要,不进入深度扫描
注意:
- URL 过滤只是性能优化,不是唯一命中依据
- 真正成功与否仍由数据层提取结果决定
## 9. 数据提取策略
提取逻辑必须从拦截层中拆出,做成独立纯函数,以便单元测试。
推荐接口:
```ts
extractAfterSearchRates(payload: unknown): {
matched: boolean
success: boolean
extractorLevel: "exact-key" | "label-value" | "text-fallback" | "none"
rates?: {
singleVideoAfterSearchRate?: string
personalVideoAfterSearchRate?: string
}
rawPathHints: string[]
matchedLabels?: string[]
candidateStarId?: string
reason?: string
}
```
### 9.1 一级:精确字段匹配
优先尝试命中明显字段名,例如包含:
- `after_search_rate`
- `search_rate`
- `lookback_search`
- `kanhousou`
如果未来真实响应中存在明确 key,这一层应最稳定。
但要避免把泛化字段误判为目标值:
- 不要因为单独出现 `search_rate` 就直接判定命中
- 至少要求同层或近邻上下文同时出现 `after_search``看后搜` 等语义信号
- 若只能命中一个疑似字段,应降级为 `matched: true``success: false`
### 9.2 二级:半结构化 label/value 匹配
若响应是卡片化数据,可能不存在固定 key,而是类似:
- 某个数组项里包含 label 与 value
- label 为“单视频看后搜率”“单条视频看后搜率”“个人视频看后搜率”或近似文案
此时应按 label 识别两项值,并记录命中的对象路径。
实现上建议先做 label 标准化,再走小型同义词表:
- 去掉空格、全半角差异和无意义标点
- 将近义 label 归一到固定内部字段名
- 只在同一局部对象或同一卡片内成对提取,避免跨模块拼接出伪成功
### 9.3 三级:文本兜底匹配
若响应中没有明显结构,但存在文本片段,可用正则从附近文本中提取类似:
- `0.5% - 1%`
- `0.5%-1%`
文本兜底只用于实验,不应作为长期主路径。
为降低误判,文本兜底应限制在“已经被判定为相关卡片或相关子树”的局部文本中,不建议对整份响应做无上下文全文扫描。
### 9.4 成功判定
只有当以下条件同时满足时才记为成功:
- 找到单视频或单条视频看后搜率
- 找到个人视频看后搜率
任意一项缺失,都视为失败或半命中,不得报成功。
## 10. 达人 ID 提取规则
达人 ID 提取优先级:
1. 从当前详情页 URL 路径中提取 `pageStarId`
2. 如果响应体中也出现达人 ID,则作为 `responseStarId` 附加记录
3. 若两者不一致,不得用响应值覆盖页面值,而是保留双方并标记 `idMismatch: true`
4. `routeKey` 统一基于页面路由生成,不基于响应体里的达人 ID 反推
## 11. 消息桥设计
页面上下文与内容脚本之间使用 `window.postMessage` 通信。
消息建议形如:
```js
{
source: "star-chart-search-enhancer",
type: "AFTER_SEARCH_RATE_RESULT",
payload: {
success: true,
stage: "captured",
routeKey: "...",
pageStarId: "...",
matchedRequestUrl: "...",
extractorLevel: "label-value",
rates: {
singleVideoAfterSearchRate: "...",
personalVideoAfterSearchRate: "..."
},
rawPathHints: []
}
}
```
内容脚本需要做来源过滤:
- 只接受 `window === event.source` 的消息
- 只接受固定 `source``type`
- 校验 `payload` 的必要字段和字段类型
- 丢弃 `routeKey` 不是当前页面路由快照的陈旧消息
## 12. 日志与调试策略
实验阶段先不做复杂 UI,统一走控制台日志。
建议分三级输出:
- `info`
- 插件已注入
- 当前页面匹配详情页
- 当前达人 ID
- 当前 `routeKey`
- `debug`
- 命中候选请求 URL
- JSON 解析是否成功
- 提取逻辑命中了哪一级规则
- `result`
- 最终结构化结果对象
所有日志建议统一加前缀,例如 `[star-chart-search-enhancer]`
日志只打印结构化结果、阶段信息、候选请求摘要和路径提示,不打印整份原始响应体。原因:
- 原始响应通常体积较大,会降低调试可读性
- 站点响应可能包含与本实验无关的业务字段,不应在控制台无约束暴露
- 对提取逻辑来说,`matchedRequestUrl``extractorLevel``rawPathHints`、候选 label 摘要通常已经足够排查
如确需保留真实样本做后续测试,应该采用脱敏后的 fixture 文件,而不是直接把原始 payload 打到控制台。
如果同页面多次命中:
- 同一 `routeKey` 默认只打印一个最终 `result`
- 若先出现失败终态、后出现完整成功结果,可以用成功结果覆盖前一次终态
- 若命中的是相同结果指纹,则不重复打印,避免控制台刷屏
## 13. SPA 与重复进入处理
巨量星图页面可能存在单页路由切换,因此需要兼顾以下情况:
- `history.pushState`
- `history.replaceState`
- `popstate`
建议为每次详情页进入生成一个新的 `routeKey`,例如:
```text
${pageStarId ?? "unknown"}::${location.pathname}::${navigationSeq}
```
在检测到路由切换到新的达人详情页时,插件应:
- 递增 `navigationSeq`
- 重置当前页面的命中缓存
- 更新当前达人 ID
- 广播新的 `routeKey` 给注入层或在注入层同步读取
- 等待后续请求再次命中
这样可以避免前一路由的慢响应在新页面落地后被错误归到当前达人。
## 14. 失败兜底策略
### 14.1 注入太晚
如果内容脚本注入时页面关键请求已经发完,则本轮可能拿不到数据。实验接受手工刷新一次页面作为前提条件。
### 14.2 拿到响应但不是可解析 JSON
记录:
- 请求 URL
- 请求方法
- 状态码
但不记为成功。
### 14.3 命中候选响应但字段未识别
输出:
- 命中的请求 URL
- 失败原因
- 可疑路径提示或 label 值摘要
便于后续迭代提取规则。
### 14.4 观察窗口超时
若在单次详情页进入或路由切换后的观察窗口内始终没有拿到完整成功结果,应输出一个明确的失败终态,避免用户无法区分“还在等待”与“本轮失败”。
建议:
- 观察窗口默认设为 8 到 10 秒
- 超时结果使用 `success: false``stage: "timeout"`
- 结果中附带 `candidateRequestCount``lastCandidateRequestUrl``pageStarId``routeKey`
### 14.5 多次命中或重复消息
同一页面多次命中时:
- 如果前一次不完整、后一次完整,保留后一次
- 如果都是完整结果,默认保留最新一次
- 对完全相同的 `routeKey + matchedRequestUrl + normalizedRates` 结果做去重
## 15. TDD 与测试策略
本实验虽然是浏览器插件,但提取逻辑必须先做测试驱动,避免每次改规则都依赖人工打开目标站点。
### 15.1 待测模块拆分
建议拆为三类模块:
- `extractors`
- 只负责从 JSON 中提取两个看后搜率
- 纯函数,可完整单测
- `page-hook`
- 负责拦截网络请求、调用提取器、发消息
- 可做轻量行为测试
- `content-bridge`
- 负责注入、接收消息、打印结果
- 可做消息桥测试
### 15.2 单元测试优先级
首批测试用例至少覆盖:
- 标准固定 key 命中
- label/value 结构命中
- 文本兜底命中
- 仅命中一个值时不能算成功
- 完全无关响应不应误判
- 百分比范围字符串存在空格或无空格时仍可识别
- 路由切换后的陈旧消息会被丢弃
- `fetch` 包装通过 `clone()` 读取,不消费原始响应
- hook 内部抛错时请求仍然正常返回
- 超时路径会输出明确失败结果
一旦首次命中真实页面响应,应立刻补充一组匿名化 fixture 测试:
- 从真实响应中删去无关字段与敏感标识
- 固化为最小可复现样本
- 用该 fixture 保护当前提取规则,避免后续重构把已验证路径改坏
### 15.3 集成验证
最小人工验证步骤:
1. 加载未打包的 Chrome 插件
2. 打开巨量星图达人详情页
3. 刷新页面一次
4. 打开 DevTools Console
5. 检查是否出现结构化结果对象
6. 将结果中的两个值与页面展示值逐项比对
### 15.4 成功证据
只有同时满足以下条件,才可宣布实验通过:
- 控制台出现结构化结果
- 两个值都存在
- 两个值与页面右侧显示一致
## 16. 建议的最小代码结构
建议以最小清晰结构开始,不为未来需求过度设计:
```text
src/
manifest.json
content/
index.ts
route-state.ts
page/
hook.ts
network-interceptor.ts
shared/
extract-after-search-rates.ts
normalize-rate-label.ts
get-star-id.ts
route-key.ts
message-types.ts
result-types.ts
tests/
extract-after-search-rates.test.ts
content-bridge.test.ts
page-hook.test.ts
route-state.test.ts
```
如果当前项目尚未有脚手架,可在实现阶段再补构建工具,不在本设计阶段提前锁死。
## 17. 实现边界
首版只交付以下能力:
- 一个可加载到 Chrome 的最小 MV3 扩展
- 在达人详情页自动注入 hook
- 自动抓取并输出两个看后搜率
- 覆盖关键提取逻辑的测试
- 明确的超时失败结果与去重策略
首版不交付:
- 列表页注入指标
- 批量抓取多个达人
- 导出和缓存
- 插件弹窗管理界面
- 后台服务
- 后台常驻 worker 与云端同步
## 18. 后续扩展方向
当详情页实验成功后,下一阶段可以考虑:
- 在找达人列表页按行补充这两个指标
- 从详情页拦截结果建立字段映射,逐步定位稳定接口
- 将成功命中的接口 URL 与字段路径固化,减少全量扫描成本
- 增加插件调试面板,展示最近一次命中的原始来源与提取路径
## 19. 风险与注意事项
- 目标站点前端实现可能变化,尤其是 label 文案和响应结构
- 若站点在页面初始化前就发出关键请求,注入时机不够早会影响命中率
- 若接口数据经过额外加密、压缩或二次映射,网络拦截不一定能直接得到最终值
- 若页面使用 Service Worker、流式响应或非常规封装,可能需要补充拦截策略
- 若两个看后搜率实际来自 SSR、内联脚本或前端二次计算,网络拦截实验会出现稳定超时,此时应尽快切换实验路径
## 20. 当前结论
在当前约束下,最合理的最小实验路径是:
- 不依赖 `search_session_id`
- 仅支持达人详情页
- 通过页面上下文拦截 `fetch/XHR`
- 将数据提取逻辑做成纯函数并用测试保护
-`routeKey`、超时终态和去重规则保证日志可判读
- 成功后先用控制台结构化日志作为验收依据
只要详情页的两个看后搜率确实来自页面可见的 JSON 响应,这个实验具备较高可行性。
如果连续多次只得到 `timeout` 或无关候选响应,下一步应优先验证“数据是否根本不走网络响应体”这一前提,而不是继续扩大提取规则范围。
@@ -1,623 +0,0 @@
# 星图找达人列表页看后搜率列增强设计(阶段 1)
## 1. 背景
前置实验已经验证:
- Chrome MV3 扩展可以在巨量星图详情页稳定运行
- 当前仓库已经存在详情页 `content/page/shared` 基础结构、构建脚本和测试
- 插件已经能够识别真实接口响应,并确认可用指标接口为:
- `/gw/api/aggregator/get_author_ase_info?author_id=<id>&range=30`
- 已确认字段映射:
- `avg_search_after_view_rate` -> `单视频看后搜率`
- `personal_avg_search_after_view_rate` -> `个人视频看后搜率`
用户当前的新目标不是继续在详情页控制台验证,而是进入找达人列表页,在表格中直接新增两列,把这两个值自动补齐并显示出来。
本阶段是“现有插件能力的列表页扩展”,不是重新从空仓库设计一套插件。
## 2. 阶段拆分
本需求拆为两个阶段:
### 阶段 1
只处理当前列表页当前结果页:
- 列表页自动新增两列
- 页面进入后自动为当前页所有达人加载这两个值
- 支持筛选、翻页、搜索、排序变化后的自动重跑
- 支持同达人内存缓存
- 支持失败后按整行重试
### 阶段 2
在阶段 1 稳定后再做:
- 按当前筛选条件拉取全部达人
- 插件自己的导出按钮与导出逻辑
- 导出结果中带上这两个新增字段
本设计文档只覆盖阶段 1,但会为阶段 2 预留状态结构与数据命名。
## 3. 目标
阶段 1 的目标是:
- 在找达人列表页自动插入两列:
- `单视频看后搜率`
- `个人视频看后搜率`
- 进入页面后自动批量加载当前页所有达人这两个值
- 在列表变化后自动重新补齐当前页
- 成功时展示真实值
- 失败时展示 `加载失败`
- 点击任一失败单元格,按整行重试
- 对同达人做内存缓存,避免重复请求
- 不回归现有详情页能力与已有测试
## 4. 非目标
阶段 1 明确不做:
- 不处理全部结果导出
- 不接管页面原生导出按钮
- 不实现插件自己的导出按钮
- 不支持按这两列排序
- 不持久化缓存到 `storage`
- 不做批量抓取全部分页结果
- 不修改页面原始列表接口响应
- 不把列表页逻辑继续堆进现有详情页 controller 内
- 不复用详情页“通用响应扫描提取器”作为列表页主路径
- 不接入后端服务
## 5. 用户已确认的产品约束
- 两列形式:插入成两列
- 列位置:放在最右侧 `操作` 列前
- 首次状态:显示 `加载中...`
- 失败状态:显示 `加载失败`
- 失败重试:点击任一失败单元格,按整行重试
- 加载方式:页面进入后自动批量加载当前页所有达人
- 缓存方式:内存缓存
- 表头文案:使用完整名称
- 排序能力:不支持
- 页面变化后行为:翻页、切筛选、搜索、排序变化后都自动重新补齐当前页
- 阶段拆分:接受先做当前页列表增强,再做全部导出
- 现有详情页实验链路继续保留,不因列表页阶段 1 被破坏
## 6. 方案对比
### 方案 1DOM 增强 + content script 主动请求已验证接口
在列表页中识别表头和数据行,插入两列,然后由列表页 content script 主动请求已验证的指标接口补齐值。
优点:
- 最贴合当前仓库现状,不必引入新的页面注入资产
- 不需要复用详情页的网络 hook 扫描逻辑
- 风险集中在 DOM 识别、请求调度和状态回写上,边界清楚
- 后续导出阶段可复用同一批量请求与字段规范化链路
缺点:
- 需要稳定提取每行达人 `author_id`
- 需要自己维护并发、缓存、失败重试和 DOM 更新
- 需要显式处理列表变化后的陈旧结果抑制
### 方案 2:DOM 增强 + 页面上下文请求桥
仍然做 DOM 增强,但实际请求不在 content script 中发,而是通过页面上下文桥接后由页面环境发起。
优点:
- 如果列表页接口对隔离环境请求有限制,这条路径更稳
缺点:
- 会引入新的页面资产、消息桥和构建改动
- 阶段 1 复杂度明显上升
- 当前没有证据表明必须这样做
### 方案 3:劫持列表接口并补字段后再交还页面
拦截找达人列表接口响应,修改返回对象,尝试让页面自己渲染新列数据。
优点:
- 理论上更接近“原生数据”
缺点:
- 侵入性高
- 列表接口未必包含或容易关联这两个字段
- 需要同时控制页面渲染结构和数据结构,不适合阶段 1
### 推荐结论
采用 `方案 1DOM 增强 + content script 主动请求已验证接口`
同时明确一条边界:
- 阶段 1 不做“双通道请求”
- 如果后续实测证明 content script 的同源请求在列表页不可行,应停下重新修订设计,而不是临时在实现中偷偷加第二套请求桥
## 7. 页面范围
阶段 1 只在找达人列表页启用,例如:
- `https://*.xingtu.cn/ad/creator/market*`
详情页逻辑保留,作为已验证链路和回归基线,但不作为阶段 1 的交付重点。
## 8. 数据源与请求策略
### 8.1 已确认接口
阶段 1 直接请求:
```text
/gw/api/aggregator/get_author_ase_info?author_id=<id>&range=30
```
### 8.2 请求执行上下文
阶段 1 的请求由列表页 content script 发起,不复用详情页的页面 hook。
请求约束建议写死:
- 使用 `fetch`
- `method: "GET"`
- `credentials: "include"`
-`AbortController` 控制单请求超时
- 不额外伪造签名,不改写页面 Cookie,不依赖 `search_session_id`
- 建议默认超时预算固定为 `8000ms`
这意味着列表页实现与详情页“拦截页面自身请求”的实验链路是两条不同路径,阶段 1 不需要把它们硬绑在一起。
### 8.3 响应映射策略
列表页阶段 1 不应再走 `extractAfterSearchRates(payload)` 这种“泛化扫描整个响应”的路径,而应使用一个针对已知接口的专用映射函数,例如:
```ts
mapAuthorAseInfoResponse(payload: unknown): {
success: boolean
rates?: {
singleVideoAfterSearchRate: string
personalVideoAfterSearchRate: string
}
reason?: "bad-response" | "missing-field"
}
```
原因:
- 这个接口的字段已经明确,没必要再走启发式扫描
- 继续复用详情页提取器会把阶段 1 绑定到不必要的共享行为上
- 专用 mapper 更容易测、更不容易误判,也更适合阶段 2 复用
失败分类也应固定下来:
- `AbortError` 或主动超时中止,归类为 `timeout`
- 网络异常、非 2xx、JSON 解析失败,归类为 `request-failed`
- 成功拿到响应但缺字段或结构不符,归类为 `bad-response`
### 8.4 字段与格式规范化
当前已确认的真实值格式包括:
- `<0.02%`
- `0.02 - 0.1%`
展示前统一做轻量规范化:
- `<0.02%` 保留
- `0.02 - 0.1%` 规范为 `0.02% - 0.1%`
若任一目标字段缺失,都视为本次响应不可用,不报成功。
## 9. 与当前仓库的适配约束
当前仓库已经有一套详情页入口、结果类型和测试基线。阶段 1 应按“并列模块”接入,而不是直接把市场页逻辑塞进现有详情页实现里。
建议约束:
- `src/content/index.ts` 只负责做路由分发与公共 bootstrap
- 现有详情页 controller 迁移为独立模块,避免被市场页逻辑污染
- 市场页能力在 `src/content/market/` 下实现
- 详情页现有 `src/page/*` 注入链路阶段 1 不改或尽量少改
这样可以把列表页新增复杂度限制在 content 层,不去动已经稳定的详情页 page hook。
## 10. 列表页架构与数据流
整体数据流如下:
1. 用户进入找达人列表页
2. 内容脚本入口识别当前为 `market` 路由,启动 market controller
3. market controller 识别主表、表头与当前行集合
4.`操作` 列前插入两列表头
5. 为当前同步周期生成新的 `listSeq`
6. 对每一行提取达人 `author_id`
7. 先渲染行初始状态:
-`author_id` 的行为 `加载中...`
- 无法提取 `author_id` 的行为 `加载失败`
8. 调度器按并发限制批量请求 `get_author_ase_info`
9. 成功后把对应行更新为真实值
10. 失败后将该行两列更新为 `加载失败`
11. 点击失败单元格时,以该行 `author_id` 为单位重试
12. 翻页、筛选、搜索、排序变化后,生成新的 `listSeq` 并重新同步当前页
## 11. DOM 识别与插入策略
### 11.1 表格识别
优先以“表头文本语义 + 表格结构”识别主列表,而不是依赖脆弱 class 名:
- 找到包含 `达人信息``操作` 等列标题的主表头
- 找到其对应的数据行容器
- 允许表头和数据区不是同一 DOM 层级
### 11.2 表头插入
插入规则固定为:
- 找到标题为 `操作` 的列
- 在其前插入:
- `单视频看后搜率`
- `个人视频看后搜率`
`操作` 列暂时识别失败:
- 不盲目插列
- 记录 debug 日志
- 等待 DOM 下一次稳定后再尝试
### 11.3 行单元格插入
对每一行:
- 找到对应的 `操作` 单元格
- 在它前面插入两个插件单元格
- 插件单元格带稳定 `data-*` 标记
- 插件单元格额外记录:
- `data-sces-author-id`
- `data-sces-list-seq`
- `data-sces-column`
### 11.4 DOM 复用边界
实现时不要把 `HTMLElement` 长久缓存到请求层或缓存层。
原因:
- 列表页可能替换整块 DOM
- 页面也可能复用行节点或重排节点
- 长持有旧节点会导致结果回写错位
正确方式是:
- 每次同步重新扫描当前可见行
- 只在渲染阶段临时持有当前节点引用
- 请求层、缓存层、批量调度层只保存 `authorId``listSeq``signature` 等轻量标识
- 异步结果回写前先校验 `listSeq``authorId` 绑定仍然匹配
## 12. 达人 ID 提取策略
阶段 1 的关键依赖是从每一行稳定拿到达人 `author_id`
优先级如下:
1. 从行内详情页链接提取
若头像、昵称、封面、跳转按钮链接指向达人详情页,则直接从 URL 中提取达人 ID。
2. 从行内 `data-*`、埋点属性、按钮参数中提取
若页面在行内直接存了作者 ID,优先复用。
3. 若当前行无法提取 ID
不猜测,不发请求。该行两列显示 `加载失败`,并记录 debug 原因 `missing-author-id`
阶段 1 不依赖:
- `search_session_id`
- 当前页排序位置
- 当前页行号
## 13. 列表身份与陈旧结果抑制
阶段 1 需要明确区分“当前这次列表同步”和“前一次列表同步”。
建议引入:
```ts
type ListSession = {
listSeq: number
signature: string
}
```
其中:
- `listSeq` 每次检测到列表数据源变化时递增
- `signature` 由当前页 `authorId` 列表和必要的 URL 查询参数组成
所有异步回写都必须满足:
- 结果对应的 `listSeq` 仍等于当前 controller 的 `listSeq`
- 行节点上的 `data-sces-list-seq` 与结果中的 `listSeq` 一致
- 行节点上的 `data-sces-author-id` 与结果中的 `authorId` 一致
否则直接丢弃,不允许写回旧列表结果。
实现上还应补一条:
- 真正写回前重新从当前 DOM 查询目标行和目标单元格,而不是信任旧闭包里的节点引用
## 14. 状态模型
每一行使用统一状态,驱动两列一起更新:
```ts
type RowStatus =
| {
state: "loading"
authorId: string
listSeq: number
}
| {
state: "success"
authorId: string
listSeq: number
source: "cache" | "network"
singleVideoAfterSearchRate: string
personalVideoAfterSearchRate: string
}
| {
state: "error"
authorId: string | null
listSeq: number
retryable: boolean
reason: "request-failed" | "timeout" | "missing-author-id" | "bad-response"
}
```
两个单元格共用一份行状态,而不是各自独立状态。
## 15. 缓存与请求去重
阶段 1 使用内存缓存:
```ts
Map<authorId, CacheEntry>
```
缓存项建议包含:
- `status`
- `rates`
- `updatedAt`
- `inflightPromise`
行为规则:
- 同一达人在同一标签页会话内再次出现时,优先复用成功缓存
- 若该达人正在请求中,不重复发请求,复用同一 `inflightPromise`
- `missing-author-id` 不进入 `authorId` 缓存
- 瞬时错误不作为长期成功缓存保存;用户点击重试时必须允许重新发请求
阶段 1 不做持久缓存。页面刷新后缓存丢失是接受的。
## 16. 批量加载、并发与重试策略
因为页面进入后要自动为当前页所有达人批量加载,所以必须限制并发,避免过载或拖慢页面。
建议:
- 当前页自动批量加载
- 并发上限设置为 `4`
- 剩余任务排队
- 单请求有独立超时
调度规则建议写清:
- 当前页有 `authorId` 的行先全部进入 `加载中...`
- 然后逐批更新
- 列表变化后,未开始的旧任务直接丢弃
- 已发出的旧任务即使返回,也必须经过 `listSeq` 校验后才能回写
- 批量调度器只负责返回 `authorId + listSeq + result`,不直接持有或操作 DOM
失败重试规则:
- 点击任一失败单元格,只重试该行对应的 `authorId`
- 两列一起切回 `加载中...`
- 若该达人当前已有 `inflightPromise`,则直接复用,不重复起请求
## 17. 列表变化监听
阶段 1 需要自动响应以下变化:
- 翻页
- 切换筛选
- 重新搜索
- 切换排序
推荐统一抽象成“列表数据源变化”,而不是分别写四套逻辑。
实现上建议组合使用:
- `MutationObserver` 观察表格区域变化
- 当前行 `authorId` 列表签名
- 当前 URL / 查询参数变化
当以下任一变化发生时,触发一次新同步:
- 当前页行集合改变
- 列表主容器被替换
- 搜索参数或分页参数改变
## 18. 渲染规则
### 18.1 初始态
当前页新行出现后:
-`authorId` 的行两列显示 `加载中...`
-`authorId` 的行两列显示 `加载失败`
### 18.2 成功态
成功后分别显示:
- `单视频看后搜率`
- `个人视频看后搜率`
### 18.3 失败态
失败后两个单元格都显示:
- `加载失败`
### 18.4 失败重试
点击任一失败单元格时:
- 对应整行重试
- 两列一起切回 `加载中...`
- 再次根据结果统一更新
## 19. 错误处理与日志
阶段 1 的失败场景至少包括:
- 当前行缺少 `author_id`
- 请求超时
- 请求失败
- 响应结构异常
- 返回值只拿到一项
处理原则:
- 不阻断其他行
- 单行失败不影响整页
- 失败要有明确展示
- 失败原因要能在日志中区分
日志建议保留统一前缀,例如:
- `[star-chart-search-enhancer] market-sync-start`
- `[star-chart-search-enhancer] market-row-error`
- `[star-chart-search-enhancer] market-stale-result-dropped`
## 20. TDD 策略
阶段 1 必须继续用 TDD 推进,尤其是列表页路由分发、DOM 增强和状态同步逻辑。
### 20.1 纯函数测试
新增或扩展:
- 列表页详情链接中的达人 ID 提取
- 指标接口响应到展示字段的专用 mapper
- 值格式规范化
- 列表签名生成
- 行状态机变换
### 20.2 DOM 测试
新增基于最小 DOM fixture 的测试:
-`操作` 列前插入两列表头
- 在每一行的 `操作` 单元格前插入两列
- 已插入时不重复插入
- 行状态在 `loading -> success -> error -> retry` 间正确切换
### 20.3 调度测试
新增测试覆盖:
- 当前页自动批量加载
- 同达人请求去重
- 并发上限控制
- 缓存复用
- 列表变化后旧结果不回写新列表
### 20.4 路由与构建回归测试
必须保留并扩展现有测试:
- Manifest 现在同时覆盖详情页和 `creator/market`
- 详情页原有 content/page 行为不回归
- 新的路由入口能在 market 页面启动正确 controller
## 21. 建议的代码结构调整
为了实现阶段 1,建议在现有项目基础上调整为以下职责边界:
```text
src/
content/
index.ts
detail/
index.ts
market/
index.ts
api-client.ts
batch-loader.ts
cache-store.ts
dom-sync.ts
id-extractor.ts
list-signature.ts
row-render.ts
row-state.ts
shared/
get-star-id.ts
normalize-rate-value.ts
result-types.ts
```
说明:
- 详情页 controller 应从当前 `content/index.ts` 中拆出去,避免市场页逻辑污染原实现
- 列表页阶段 1 不建议修改 `src/page/hook.ts`
- 列表页应使用专用 `api-client + mapper`,而不是借道详情页提取器
## 22. 交付边界
阶段 1 完成时,应当满足:
- 找达人列表页能自动新增两列
- 进入页面后当前页所有达人自动开始加载
- 成功时显示真实值
- 失败时显示 `加载失败`
- 点击失败单元格可按整行重试
- 翻页、搜索、筛选、排序变化后自动重跑
- 同达人内存缓存生效
- 自动化测试覆盖关键行为
- 详情页现有能力与测试继续通过
阶段 1 完成时,仍然不要求:
- 导出全部结果
- 接管原生导出按钮
- 排序这两列
- 跨刷新缓存
## 23. 风险
- 列表页 DOM 结构可能比详情页更容易变化
- 行内达人 ID 不一定总能稳定拿到
- 自动批量请求过多时可能受限流影响
- 页面自身虚拟滚动或复用行 DOM 时,可能影响状态回写
- 若 content script 的同源请求在实际页面环境受限,需要回到设计层重新决定是否引入页面请求桥
## 24. 当前结论
阶段 1 的正确方向已经明确:
- 不继续做详情页控制台实验
- 在现有仓库上增量添加 market page controller
-`操作` 列前插入两列
- 用 content script 主动请求已验证接口
- 用专用 mapper 处理已知字段,不复用详情页泛化提取器
-`listSeq`、内存缓存、整行状态和失败重试形成闭环
只要列表页能够稳定拿到每行达人 `author_id`,且 content script 对该接口的同源请求可用,阶段 1 就具备较高可行性。
@@ -0,0 +1,257 @@
# 星图市场导出范围设计
## 背景
当前插件的 `导出CSV` 只导出当前页数据,并在星图默认列后追加两列:
- `单视频看后搜率`
- `个人视频看后搜率`
现阶段用户希望把导出范围扩展为可配置页数,而不是固定当前页。同时需要保留当前默认列导出逻辑,避免重新引入之前“全量扫描导致页面闪动、卡顿、排序异常”的问题。
## 目标
- 支持导出范围配置,默认导出前 `5` 页。
- 支持以下范围选项:
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
- 保留当前 CSV 规则:
- 先导出星图页面默认列
- 末尾追加两列插件字段
- 批量导出时真实翻页采集当前筛选结果页,不改变当前页面筛选条件来源。
- 保持当前“筛选/排序只作用当前页”的行为,不重新绑定多页扫描逻辑。
## 非目标
- 不改为直接调用星图列表接口拉多页数据。
- 不让插件筛选或排序自动跨页生效。
- 不处理“导出后自动回到最初分页位置”的复杂恢复,首版只要求滚动位置恢复到单页采集前。
## 交互设计
在插件工具栏中保留一个 `导出CSV` 按钮,同时新增导出范围控件。
### 范围控件
- 默认值:`前5页`
- 可选项:
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
- 选择 `自定义` 时,展示页数输入框
- 仅接受正整数
- 无效值时禁止导出并提示错误
### 任务状态
- 导出开始后:
- `导出CSV` 按钮禁用
- 筛选按钮、排序按钮、范围控件、自定义输入框一并禁用
- 按钮文案根据任务模式更新:
- 当前页:`导出中...`
- 前 N 页:`导出中 3/5 页...`
- 全部:`导出中 第3页...`
- 全部导出时额外显示轻提示:
- `全部导出可能耗时较长,页面会自动翻页。`
### 失败反馈
- 任一页采集失败时直接终止
- 通过按钮附近状态文本或 `alert` 提示:
- `第 N 页导出失败,请稍后重试`
- 失败时不下载残缺 CSV
## 执行流程
### 当前页导出
当前页模式沿用现有逻辑:
1. 等待当前页表格可读取
2. 自动滚动当前页一次,补齐懒加载字段
3. 读取当前页星图默认列
4. 合并插件两列
5. 生成 CSV 并下载
### 多页导出
多页模式仅在导出任务中启用,不影响平时筛选和排序。
1. 读取导出范围配置
2. 初始化批量任务状态与结果聚合器
3. 对当前页执行:
- 等待表格稳定
- 自动滚动当前页以采全默认列
- 读取当前页记录
-`authorId` 合并入结果集
4. 判断是否达到目标页数
5. 若未完成,则点击下一页
6. 等待“页切换完成”信号
7. 重复步骤 3 到 6
8. 汇总所有记录,生成单个 CSV 下载
### 全部导出停止条件
`全部` 模式从当前页开始向后翻页,直到任一条件成立:
- 下一页按钮不可点击
- 当前页签名不再变化
- 页面无法继续翻页并达到超时阈值
## 页切换判定
批量导出不能只依赖固定 `setTimeout`。翻页完成需要同时满足至少一个强信号:
- 当前页作者 ID 集合发生变化
- 当前页首行作者 ID 变化
- 当前页活动分页按钮变化
- 下一页按钮状态变化
为避免误判,翻页流程需要:
1. 记录翻页前页签名
2. 点击下一页
3. 轮询直到签名变化或超时
4. 签名变化后再执行一次当前页表格稳定等待
## 当前页采集策略
当前页导出已知存在懒加载问题:页面初加载时后半段行的默认列为空,滚动后才补齐。
因此无论当前页导出还是多页导出,每一页都必须执行:
1. 初次读取当前页快照并写入 store
2. 查找当前市场列表滚动容器
3. 逐步向下滚动直到该页底部
4. 每次滚动后等待 DOM 稳定,再采集一轮
5. 恢复滚动位置并再采集一轮
这一步只针对“当前已打开的页”,不跨页扫描。
## 数据合并规则
### 去重键
- 使用 `authorId` 去重
### 字段合并
- 新页记录有值时,补充到已有记录
- 新页记录为空时,不覆盖已有非空值
- 插件两列沿用当前已有的合并规则
### CSV 表头
- 先保留星图默认列顺序
- 再追加:
- `单视频看后搜率`
- `个人视频看后搜率`
## 错误处理
### 单页失败
以下情况视为单页失败:
- 找不到表格根节点
- 找不到下一页按钮但目标尚未完成
- 翻页后页签名未变化且超时
- 当前页采集过程中出现异常
处理方式:
- 立即终止任务
- 恢复工具栏控件状态
- 显示页级错误信息
- 不触发下载
### 用户干预
首版不额外提供取消按钮,但应防御以下情况:
- 用户手动切换筛选
- 用户手动翻页
- 用户再次点击导出
处理方式:
- 导出期间禁用插件控件
- 对关键节点重新校验表格和页签名
- 如环境已被破坏,终止任务并报错
## 实现建议
建议按以下边界组织实现:
### `plugin-toolbar`
- 新增导出范围选择器
- 新增自定义页数输入框
- 新增导出过程中的状态展示
### `market/index`
- 保留当前页导出逻辑作为单页模式
- 新增批量导出入口
- 管理任务状态、按钮禁用、进度回写
### 新建批量导出控制器
建议新增类似 `export-range-controller.ts` 的模块,职责包括:
- 读取当前页记录
- 控制翻页
- 等待页切换
- 聚合多页数据
- 统一返回最终 `MarketRecord[]`
这样可以避免把 `market/index.ts` 继续堆大。
### `dom-sync` 或新辅助模块
- 提供读取当前页签名
- 提供定位下一页按钮
- 提供判断下一页是否可点击
## 测试策略
### 单元测试
- 默认导出范围为前 5 页
- 选择 `当前页` 时只导出一页
- 选择 `前10页` 时最多导出 10 页
- `自定义` 输入非法值时不启动导出
- `全部` 模式在没有下一页时停止
- 多页合并时按 `authorId` 去重
- 空字段不会覆盖已有非空字段
### 集成测试
- 批量导出时会依次点击下一页
- 翻页后等待新页签名再采集
- 每一页在导出前都会执行滚动采集
- 中途某页失败时不生成 CSV
- 导出期间工具栏控件被禁用
## 风险
- 星图分页 DOM 结构可能不稳定,下一页按钮定位需要做降级兼容。
- 全部导出在高页数场景下耗时会较长。
- 页面中途刷新或筛选重算可能打断批量任务。
## 推荐实施顺序
1. 工具栏新增范围选择与自定义输入
2. 抽出批量导出控制器
3. 实现前 N 页导出
4. 在前 N 页能力稳定后开放 `全部`
5. 补齐全部相关失败与超时测试
## 决策结论
采用“一个导出按钮 + 范围选择器”的方案,默认导出前 `5` 页,支持 `全部``自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。
@@ -0,0 +1,216 @@
# Logto 受保护 API Mock 联调设计
## 背景
当前扩展已经具备基础的 Logto 登录能力:
- popup 可触发登录和登出
- background 可通过 `@logto/chrome-extension` 获取 access token
- 页面功能在未登录时会被 auth gate 拦住
但现阶段业务数据仍然来自页面站内接口,尚未真正验证“扩展拿到 Logto access token 后,请求受保护 API”这条链路。后续真实后端将由其他人提供,因此当前阶段的目标不是接入正式接口,而是先在本地完成一套可重复验证的模拟联调方案。
## 目标
- 新增一个专用于 Logto 受保护 API 的扩展客户端。
- 扩展请求受保护 API 前,必须先通过 background 获取 access token。
- 请求时自动附加 `Authorization: Bearer <token>` 请求头。
- 提供一个本地 mock 受保护 API,用于验证扩展到后端的完整调用链路。
- 提供自动化测试,分别覆盖:
- token 注入逻辑
- mock API 授权成功/失败行为
## 非目标
- 不接入真实业务后端。
- 不做真实 JWT 签名校验、JWKS 拉取或资源权限判定。
- 不修改现有星图页面数据采集逻辑为正式后端模式。
- 不在本阶段设计复杂的 token 刷新监控或重试策略。
## 方案对比
### 方案 A:只做本地 mock 后端
- 直接搭一个假接口,扩展请求它并观察返回。
- 优点:最接近最终使用方式。
- 缺点:如果联调失败,不容易快速判断问题出在 token 注入还是 mock 服务本身。
### 方案 B:只做代码级测试
- 不起本地服务,只用测试替身验证请求头是否带 token。
- 优点:实现快,定位问题直接。
- 缺点:无法证明完整链路可运行。
### 方案 C:先代码级测试,再接本地 mock 后端
- 先用单元测试锁定 token 注入行为,再起 mock 服务完成真实联调。
- 优点:定位问题更快,同时保留完整链路验证。
- 缺点:改动稍多于单一方案。
推荐采用方案 C。
## 架构设计
### 1. 扩展受保护 API 客户端
新增一个独立客户端模块,职责如下:
- 向 background 发送 `auth:get-access-token` 消息
- 读取返回的 access token
- 使用该 token 请求指定后端地址
- 将接口成功、未授权、网络失败这三类结果转成明确错误
这个客户端不直接耦合星图 DOM,也不依赖 popup。它只负责“拿 token 并带 token 发请求”,这样后续替换正式后端时只需要调整接口地址和返回映射。
### 2. Background 认证桥接
现有 background 已支持 `auth:get-access-token`。本次不改变登录主流程,只把它当作唯一 token 来源:
- content script 不直接接触 Logto SDK
- 所有受保护 API 请求都通过 background 提供 token
这样可以保持认证逻辑集中,符合 MV3 扩展的边界约束。
### 3. 本地 mock 受保护 API
新增一个轻量本地服务作为测试后端,建议职责保持极小:
- 暴露固定测试 endpoint,例如 `/api/mock/protected`
- 检查请求头中是否存在 `Authorization`
- 如果请求头形如 `Bearer <非空字符串>`,返回固定假数据
- 如果没有该头,返回 `401`
本阶段不要求验证 token 是否来自真实 Logto,只验证“扩展是否按约定附带了 Bearer token”。
## 数据流
完整调用链路如下:
1. 扩展中的业务入口调用受保护 API 客户端
2. 客户端向 background 发送 `auth:get-access-token`
3. background 返回当前 access token
4. 客户端带上 `Authorization: Bearer <token>` 请求本地 mock API
5. mock API 校验请求头并返回假数据
6. 客户端将结果回传给调用方
失败分支:
- 如果 background 没返回合法 token,客户端直接报错,不发请求
- 如果 mock API 返回 `401`,客户端将其识别为未授权错误
- 如果请求超时或网络失败,客户端抛出网络错误
## 接口设计
### 扩展内客户端接口
建议客户端提供单一入口,例如:
- `loadProtectedMockData()`
内部行为:
- 先取 token
- 再发 GET 请求
- 返回结构化响应对象或抛出明确错误
后续替换真实后端时,可以保留同样入口,内部再切换到真实 endpoint。
### Mock API 返回
成功时返回固定 JSON,例如:
```json
{
"ok": true,
"source": "mock-protected-api",
"message": "authorized",
"receivedAuthHeader": "Bearer ..."
}
```
失败时返回:
```json
{
"ok": false,
"error": "unauthorized"
}
```
## 错误处理
### 无 token
- 判定条件:background 未返回 `auth:token`,或 token 为空字符串
- 处理方式:立即抛错,提示当前未登录或 token 不可用
### 未授权
- 判定条件:后端返回 `401``403`
- 处理方式:抛出授权失败错误,供上层展示“请重新登录”
### 网络失败
- 判定条件:fetch 抛异常、连接失败、超时
- 处理方式:抛出网络错误,不吞掉异常原因
## 实现边界
建议新增或调整如下模块:
### `src/content/market` 下新增受保护 API 客户端
- 不替换现有页面接口客户端
- 独立处理 mock 受保护 API 访问
- 便于后续把“页面抓取模式”和“后端接口模式”并行保留
### `src/shared/auth-messages.ts`
- 复用现有 `auth:get-access-token`
- 若现有消息结构足够,则不新增消息类型
### `scripts/` 或独立目录中的 mock 服务
- 提供本地测试服务启动脚本
- 默认监听本地固定端口
- 返回固定 JSON 结果
## 测试策略
### 单元测试
- 客户端请求前会先获取 access token
- 拿到 token 后,请求头中包含 `Authorization: Bearer <token>`
- token 缺失时不会发起 fetch
- 接口返回 `401` 时抛出未授权错误
- 接口返回成功时正确解析 JSON
### 联调测试
- 启动本地 mock 服务后,带 token 请求能成功
- 不带 token 请求返回 `401`
- 扩展客户端能读到 mock 返回的假数据
## 手动验证
1. 构建并加载扩展
2. 在 popup 中完成 Logto 登录
3. 启动本地 mock API
4. 触发扩展中的受保护接口请求
5. 确认 mock API 收到 `Authorization: Bearer <token>`
6. 确认扩展端收到成功响应
## 迁移到真实后端的路径
当真实后端可用时,仅需要替换以下内容:
- mock API 基地址
- 具体 endpoint 路径
- 返回数据结构映射
- 若真实后端要求额外 scope,则补充 `auth-config` 中的 scopes
核心认证链路保持不变:
- 仍由 background 提供 token
- 仍由独立客户端附带 `Bearer` 请求头
- 仍按未授权和网络错误分别处理
@@ -0,0 +1,63 @@
# Market Backend Metrics CSV Design
## Goal
Extend the existing CSV export so it includes the six backend metrics already shown in the plugin UI.
## Confirmed Decisions
- Reuse the current export flow.
- Do not add a separate backend request for CSV export.
- Read backend metrics directly from the in-memory `MarketRecord`.
- Append the six backend metrics columns after the existing CSV columns.
- Keep the existing CSV columns and ordering unchanged.
- Use these exact CSV headers:
- `看后搜率`
- `看后搜数`
- `新增A3数`
- `新增A3率`
- `CPA3`
- `cp_search`
- If a record has no backend metrics, export empty strings for these six columns.
## Architecture
- `src/content/market/csv-exporter.ts` remains the single place that defines CSV column layout.
- The exporter will keep current base columns and Xingtu rate columns, then append six backend metrics columns.
- No UI changes.
- No batch submission changes.
- No popup or config changes.
## Data Source
Each exported row will read from:
- existing fields:
- `authorId`
- `authorName`
- `location`
- `price21To60s`
- `rates.singleVideoAfterSearchRate`
- `rates.personalVideoAfterSearchRate`
- new backend metrics fields:
- `backendMetrics.afterViewSearchRate`
- `backendMetrics.afterViewSearchCount`
- `backendMetrics.a3IncreaseCount`
- `backendMetrics.newA3Rate`
- `backendMetrics.cpa3`
- `backendMetrics.cpSearch`
## Failure Handling
- Missing backend metrics: export blank cells
- Existing rate formatting behavior remains unchanged
- Backend loading state does not alter CSV structure; it only affects whether the cells contain values or blanks
## Testing
Add tests for:
- backend metric headers appended to CSV
- backend metric values exported correctly
- empty backend metric cells when metrics are absent
- no regression in current base/rate export behavior
@@ -0,0 +1,110 @@
# Market Backend Metrics Design
## Goal
Connect the current Chrome extension to the real backend search API and display six backend metrics in the plugin UI, while preserving the existing two Xingtu rate columns.
## Confirmed Decisions
- Keep the existing two Xingtu metrics unchanged:
- `singleVideoAfterSearchRate`
- `personalVideoAfterSearchRate`
- Add one new UI column named `秒探指标`.
- Render the six backend metrics inside that column as a compact 2-column metrics panel:
- `avg_after_view_search_rate`
- `avg_after_view_search_cnt`
- `avg_a3_increase_cnt`
- `avg_new_a3_rate`
- `cpa3`
- `cp_search`
- Fetch backend metrics through the background service worker, not directly from the content script.
- Use Logto access tokens from the existing background auth flow.
- Query the backend by `star_id`, not by nickname.
- Send one batched request per visible page of creators.
- Use a code-level default backend base URL. Do not build a settings UI in this change.
- Do not change CSV export or batch submission payloads in this change.
## API Contract
- Base URL:
- default to `http://192.168.31.29:8083`
- Endpoint:
- `POST /api/v1/history/talents/search`
- Headers:
- `Authorization: Bearer <Logto access token>`
- `Content-Type: application/json`
- Request body:
```json
{
"type": "star_id",
"values": ["7252982749131178039", "7290491710910496809"],
"page": 1,
"size": 100
}
```
## Architecture
### Background
- Add a new background message for backend metrics search.
- The background handler:
- gets the current Logto access token
- calls the backend search API
- returns parsed metric rows to the content script
This follows the same rule already used for protected API and batch submission: token-bearing business requests belong in the background layer.
### Content
- Replace per-row metric loading with page-level batched loading for backend metrics.
- Collect all visible `authorId` values from the current page.
- Send them to the background as `star_id[]`.
- Map the backend response back to rows by `star_id`.
### UI
- Keep the old two columns where they are today.
- Add one new `秒探指标` column.
- Each cell shows:
- `加载中...`
- a compact 6-metric panel
- `暂无数据`
- `加载失败`
## Data Model
Add a new backend metrics structure to market records:
- `afterViewSearchRate`
- `afterViewSearchCount`
- `a3IncreaseCount`
- `newA3Rate`
- `cpa3`
- `cpSearch`
The old Xingtu rates stay in the existing `rates` structure.
## Failure Handling
- No token: show `加载失败`
- Backend request failure: show `加载失败`
- Backend success but row missing in response: show `暂无数据`
- Partial page matches: render matched rows, mark unmatched rows as `暂无数据`
## Testing
- Add failing tests first for:
- backend metrics request message handling
- backend metrics client request/response mapping
- page-level batch loading by `star_id`
- UI rendering of the new `秒探指标` column for loading, success, missing, and failure states
- Run focused tests first, then full `npm test`, then `npm run build`
## Out of Scope
- Popup-based backend URL settings
- CSV export changes for the six backend metrics
- Batch submit payload changes
- Cross-page deduplicated backend caching beyond current in-memory record storage
@@ -0,0 +1,305 @@
# 星图达人批次提交设计
## 背景
当前插件已经具备以下能力:
- 使用 Logto 登录并获取访问 `https://talent-search.intelligrow.cn` 的 access token
- 读取星图市场页中的达人列表数据
- 支持按当前页、前 5 页、前 10 页、全部、自定义页数进行多页采集
- 支持导出 CSV
下一阶段需要把“当前采集范围内的一批达人”提交给其他同学维护的后端接口,而不是逐条发送。用户期望在现有导出范围逻辑基础上,新增一个独立的“提交批次”动作,用来测试整批提交流程。
## 目标
- 在现有工具栏中新增一个独立的 `提交批次` 按钮
- 提交动作复用当前导出范围逻辑,支持多页达人数据采集
- 提交前通过浏览器 `prompt()` 让用户输入批次名称
- 自动生成 `batchId = 批次名称 + 时间戳`
- 将以下信息整合为单个批次 payload 发送给后端:
- Logto 用户 ID
- 星图达人 ID 列表
- 用户输入的批次名称
- 自动生成的批次 ID
## 非目标
- 不改造为逐条提交达人
- 不移除或替代现有 `导出 CSV`
- 不在首版实现复杂弹窗或表单 UI
- 不在首版实现批次列表管理、重试历史、草稿保存
## 方案对比
### 方案 A:只提交当前页
- 新按钮只处理当前页达人
- 优点:实现最简单
- 缺点:与现有多页导出/采集使用方式不一致,不符合真实业务需求
### 方案 B:提交当前导出范围内的一整批达人
- 复用现有导出范围控制器,提交当前选择范围内的所有达人
- 优点:与用户当前使用习惯一致,便于从测试过渡到真实业务
- 缺点:比只提交当前页多一层批次组装逻辑
### 方案 C:先导出 CSV,再从 CSV 结果提交
- 先走导出逻辑,再把导出结果转换为提交 payload
- 优点:数据来源统一
- 缺点:流程绕、耦合高,不适合作为长期模式
推荐采用方案 B。
## 交互设计
### 工具栏按钮
在现有工具栏中保留 `导出 CSV`,并新增:
- `提交批次`
两个按钮共享现有导出范围控件:
- 当前页
- 前 5 页
- 前 10 页
- 全部
- 自定义
### 批次名称输入
用户点击 `提交批次` 时:
1. 弹出浏览器 `prompt()`
2. 提示文案建议为:`请输入批次名称`
3. 用户输入如:`618 达人筛选第一批`
处理规则:
- 用户取消:直接终止,不报错
- 输入为空或仅空格:提示 `请输入批次名称`
- 输入有效:继续批次提交流程
### 提交状态
批次提交过程中:
- `提交批次` 按钮禁用
- `导出 CSV` 按钮与范围选择器一并禁用
- 状态文案可复用现有导出状态区域
建议状态文案:
- 当前页:`提交中...`
- 前 N 页:`提交中 3/5 页...`
- 全部:`提交中 第 3 页...`
### 成功与失败反馈
成功:
- 提示 `批次提交成功`
失败:
- 未登录:`请先登录插件`
- 批次名为空:`请输入批次名称`
- 接口失败:`批次提交失败,请稍后重试`
## 批次数据模型
首版建议 payload 结构如下:
```json
{
"logtoUserId": "p7pdhhtde8kj",
"creatorName": "王少卿",
"resource": "https://talent-search.intelligrow.cn",
"batchName": "618达人筛选第一批",
"batchId": "618达人筛选第一批-2026-04-22T12:30:00.000Z",
"createdAt": "2026-04-22T12:30:00.000Z",
"authors": [
{
"authorId": "111",
"authorName": "达人A"
},
{
"authorId": "222",
"authorName": "达人B"
}
]
}
```
### 字段来源
- `logtoUserId`
- 来源:当前登录用户的 Logto `sub`
- `creatorName`
- 来源:当前登录用户 `name``username`
- `resource`
- 来源:当前认证配置中的 API resource
- `batchName`
- 来源:用户在 `prompt()` 中输入
- `createdAt`
- 来源:当前时间的 ISO 字符串
- `batchId`
- 规则:`${batchName}-${createdAt}`
- `authors`
- 来源:当前导出范围内采集出的达人记录
### 达人字段
首版只要求最小字段:
- `authorId`
- `authorName`
后续如有需要,可增加:
- `price21To60s`
- 看后搜率字段
- 城市/地区等补充信息
## 数据流
完整流程如下:
1. 用户在工具栏选择导出范围
2. 用户点击 `提交批次`
3. 插件弹出 `prompt()` 请求输入批次名称
4. 插件检查登录状态与用户信息
5. 插件复用当前多页采集逻辑,获取当前范围内达人记录
6. 插件读取 Logto 用户 ID
7. 插件生成 `createdAt``batchId`
8. 插件组装整批 payload
9. 插件使用带 Bearer token 的客户端提交到后端
10. 插件显示成功或失败状态
## 模块边界
建议按以下边界实现:
### 工具栏层
职责:
- 渲染 `提交批次` 按钮
- 管理按钮禁用态
- 回传点击事件
### 市场控制器层
职责:
- 响应 `提交批次` 点击
- 调用 `prompt()` 获取批次名称
- 复用现有导出范围采集逻辑
- 调用批次 payload 组装器和提交客户端
### 批次 payload 组装层
职责:
- 接收用户信息、批次名、时间戳、达人记录
- 生成标准批次对象
该层不负责网络请求,方便后续根据后端要求调整结构。
### 批次提交客户端
职责:
- 获取 access token
- 附带 `Authorization: Bearer <token>` 请求头
- 向后端提交整批 JSON
后续从 mock 接口切到真实接口时,应主要修改这一层。
## 认证与授权
当前系统已具备:
- Logto 登录能力
- 请求 `https://talent-search.intelligrow.cn` resource 的 access token
- `talent-search:read` scope
首版批次提交如果是写操作,后端后续可能需要新增写权限,例如:
- `talent-search:write`
-`talent-search:batch:submit`
如果后端要求新的写权限,则前端需要同步更新 `auth-config.ts` 中的 `scopes`
## 错误处理
### 用户取消输入
- 直接停止,不提示错误
### 批次名为空
- 不发请求
- 直接提示 `请输入批次名称`
### 未登录或取不到用户信息
- 不发请求
- 提示 `请先登录插件`
### 采集失败
- 复用当前导出多页采集失败逻辑
- 不生成不完整 payload
### 接口失败
- 提示 `批次提交失败,请稍后重试`
## 测试策略
### 单元测试
- 点击 `提交批次` 时会弹出 `prompt()`
- 用户取消输入时不会继续提交
- 空白批次名会报错
- `batchId``批次名 + ISO 时间戳` 生成
- payload 中包含:
- `logtoUserId`
- `batchName`
- `batchId`
- `authors[]`
### 集成测试
- `提交批次` 复用当前导出范围采集逻辑
- 当前页模式只提交当前页达人
- 多页模式会合并多页达人
- 接口调用时带 Bearer token
- 成功时显示成功状态
- 失败时显示失败状态
## 手动验证
1. 登录插件
2. 选择导出范围
3. 点击 `提交批次`
4.`prompt()` 中输入批次名
5. 确认请求 payload 中包含:
- Logto 用户 ID
- 多个达人 ID
- 批次名
- 批次 ID
6. 确认后端返回成功
## 后续扩展
当真实接口稳定后,可以继续扩展:
- 批次提交结果详情展示
- 最近提交批次列表
- 批次重试
- 批次备注、标签
- 写权限 scope 单独拆分
@@ -0,0 +1,35 @@
# Market Scrollable Plugin Columns Design
**Goal:** Keep the plugin-generated columns visible without letting them cover Xingtu's native middle columns.
**Problem:** The current implementation injects plugin columns into the right sticky section. That expands the sticky width and causes native middle columns such as `预期播放量` and `互动率` to be visually covered.
**Approved Direction:** Keep plugin columns always visible, but move them out of the right sticky area. The plugin columns should live in the horizontally scrollable middle area so they scroll together with the native table columns.
## Design
### Layout
- Preserve the native left sticky author section.
- Preserve the native right sticky section for Xingtu's own right-side columns, especially `21-60s报价` and `操作`.
- Insert plugin columns as a separate non-sticky section immediately before the right sticky section.
- Let the plugin section participate in the same horizontal flow as the middle columns so users reach it through horizontal scrolling.
### DOM Sync Responsibilities
- `syncDivGridRoot()` remains responsible for locating the author section, middle columns, and right sticky section.
- Plugin header cells and plugin body columns should no longer be inserted into `actionHeader.parentElement` / `actionColumn.parentElement`.
- A dedicated plugin section should be created or reused under the header row and body row.
- Row alignment logic should still read plugin cells row-by-row for rendering, visibility, and ordering.
### Behavior
- Export, filtering, sorting, and row hydration should keep working unchanged.
- Only column placement changes.
- Existing synthetic table mode is unaffected.
### Testing
- Add regression coverage proving the right sticky section width stays at the native width.
- Add regression coverage proving plugin columns render in a separate non-sticky section.
- Keep existing export and hydration behavior green.
@@ -0,0 +1,94 @@
# Market Native Action Bar Design
## Goal
把插件当前位于页面顶部的工具栏移除,并将真正保留的市场页动作迁移到星图原生操作区中,与 `自定义指标` 和原生 `导出` 处于同一行。
## Confirmed Decisions
- 删除页面顶部整块插件工具栏。
- 只保留这几个插件能力:
- 导出范围选择
- 自定义页数输入
- `导出 CSV`
- `提交批次`
- 状态文案
- 排序和筛选控件从插件工具栏中删除。
- 新的插件操作区放在 `自定义指标` 和原生 `导出` 左侧。
- 主按钮风格尽量复用星图当前页的原生 `xt-button` / `el-button` 样式。
- 不新增弹窗式复杂交互;导出范围继续直接显示在操作区内。
## Layout
### Placement
- 在市场页标题区按钮行内查找 `自定义指标` 和原生 `导出` 所在的横向容器。
- 在该容器中插入一个插件 action bar。
- 插件 action bar 放在这两个原生按钮的左边。
### Visual Structure
插件 action bar 包含:
- `导出范围` 下拉框
- `自定义页数` 输入框,仅在 `自定义` 范围下显示
- `导出 CSV` 按钮
- `提交批次` 按钮
- 状态文本
视觉目标:
- 两个主按钮与原生按钮高度、圆角、边框、字体风格一致
- 范围选择器和页数输入框比按钮略窄,但整体高度对齐
- 状态文案弱化显示,不抢占主要视觉注意力
## Behavior
- `导出 CSV``提交批次` 的业务逻辑保持不变。
- 导出范围逻辑保持不变:
- 当前页
- 前 5 页
- 前 10 页
- 全部
- 自定义
- 选择 `自定义` 时显示页数输入框,否则隐藏。
- 正在导出或提交时:
- `导出 CSV` 按钮禁用
- `提交批次` 按钮禁用
- 范围下拉禁用
- 自定义页数输入禁用
- 顶部原工具栏不再出现。
## DOM Strategy
- `ensurePluginToolbar()` 不再固定 prepend 到 `document.body`
- 它改为:
- 先查找市场页原生操作区
- 在原生按钮组内创建或复用插件 action bar
- 若页面局部重渲染导致节点丢失,内容脚本在后续同步中重新确保其存在
- 工具栏 DOM 结构只保留本次需要的字段,删除筛选和排序输入控件。
## Controller Impact
- `src/content/market/index.ts` 不再从工具栏读取筛选和排序输入值。
- 现有点击表头排序的能力本轮不主动扩展,但不以顶部工具栏为依赖。
- 忙碌状态、状态文本、导出范围读取逻辑继续保留。
## Testing
需要增加或更新测试,覆盖:
- 顶部旧工具栏不再挂载到 `document.body` 顶部
- 新 action bar 挂载到原生按钮组,并位于 `自定义指标` / `导出` 左侧
- 工具栏只保留导出范围、自定义页数、导出、提交、状态文案
- 自定义范围显示/隐藏页数输入框
- 导出和提交忙碌状态仍能正确禁用相关控件
- 自定义范围校验和批次提交路径不回归
## Out of Scope
- 不新增新的筛选 UI
- 不新增新的排序 UI
- 不重做表头排序交互
- 不改动 CSV 内容结构
- 不改动批次 payload 结构
@@ -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`**
@@ -0,0 +1,466 @@
# Logto 登录集成设计
日期:2026-04-21
## 1. 背景
当前项目是一个 Chrome MV3 扩展,主要运行在星图市场页面,通过内容脚本注入页面并提供筛选、排序、导出等增强能力。
下一阶段需要为插件补齐登录能力,接入 Logto,目标是:
- 让插件具备统一的登录态
- 登录后可为未来的业务后端 API 获取 access token
- 未登录时禁止使用当前插件能力
- 这一版先打通登录、登出、会话持久化和 token 获取链路,不接真实业务后端
## 2. 已确认的产品决策
- 登录入口放在扩展 `popup`
- 目标范围是“插件登录态 + 为未来后端 API 预留 access token 获取能力”
- 当前版本暂不接真实后端 API
- 未登录时,插件功能整体禁用
- 仅允许内部已有 Logto 账号成员登录,不开放注册
- `popup` 默认显示简洁账号信息,开发模式下显示调试信息
- 未登录时,市场页禁用面板需要提供“去登录”引导
- 配置项先使用占位值,不在设计文档里写死真实 tenant / appId / resource
- 设计文档明确建议新增目录结构,而不是只写抽象功能
## 3. 官方文档约束
本设计基于以下 Logto 官方文档:
- Chrome 扩展快速开始:<https://docs.logto.io/quick-starts/chrome-extension>
- Global API resources<https://docs.logto.io/zh-CN/authorization/global-api-resources>
从官方文档抽取的关键约束如下:
- Chrome 扩展应使用适配扩展场景的登录流程,重定向 URI 基于 `chrome.identity.getRedirectURL()`
- Manifest 需要增加 `identity``storage` 等权限
- 登录流程更适合由后台脚本统一承接,而不是由内容脚本直接执行
- access token 应面向一个明确的 API resource,而不是把“登录成功”与“可访问后端 API”混为一谈
这些约束直接决定了当前项目不能把认证逻辑塞进现有内容脚本入口。
## 4. 目标与非目标
### 4.1 目标
- 在扩展 `popup` 中提供登录和登出入口
-`background service worker` 中统一处理 Logto 认证
- 在内容脚本中基于登录态控制插件可用性
- 登录成功后可读取基础用户信息
- 为未来业务后端 API 预留 `getAccessToken(resource, scopes)` 能力
- 通过 TDD 落地实现,保证认证状态切换和权限门控可回归测试
### 4.2 非目标
- 当前版本不接真实业务后端 API
- 当前版本不支持开放注册
- 当前版本不做复杂账号中心页面
- 当前版本不要求多标签页实时广播登录态变化
- 当前版本不默认展示完整 token 明文
## 5. 推荐架构
采用方案:`Background 认证中枢 + Popup 展示 + Content 只消费状态`
### 5.1 模块边界
#### `src/background/auth/*`
职责:
- 初始化 Logto 客户端
- 执行 `signIn`
- 执行 `signOut`
- 执行 `getAuthState`
- 执行 `getAccessToken`
- 统一收敛认证错误
约束:
- 它是唯一直接接触 Logto SDK 和登录流程的层
- 内容脚本和 popup 都不直接复制认证流程
#### `src/popup/*`
职责:
- 作为唯一登录入口
- 展示未登录态、已登录态、错误态
- 在开发模式下显示调试信息
- 向 background 发送认证消息
#### `src/content/*`
职责:
- 页面启动时查询认证状态
- 未登录时渲染禁用态工具栏
- 已登录后再初始化现有筛选、排序、导出能力
约束:
- 不直接依赖 Logto SDK
- 不自己发起登录
- 不长期缓存 access token
#### `src/shared/auth-messages.ts`
职责:
- 定义 popup / content / background 之间的消息协议
- 避免认证消息散落在多个文件中
### 5.2 为什么这样分层
- 符合 Logto 官方 Chrome 扩展接法
- 符合 MV3 生命周期特征,避免把认证流程绑死在不稳定的内容脚本生命周期上
- 后续接业务后端时只需要扩展 background 的 token 获取逻辑
- 权限边界清晰,未登录时不会出现“部分功能还能偷偷使用”的行为
## 6. 推荐目录结构
建议在当前项目中新增以下结构:
```text
src/
background/
auth/
client.ts
controller.ts
state.ts
types.ts
index.ts
popup/
index.html
index.ts
view.ts
types.ts
shared/
auth-messages.ts
auth-config.ts
content/
market/
auth-gate.ts
...
```
目录约束:
- `background/auth/*` 只做认证中枢,不掺杂市场业务逻辑
- `popup/*` 只处理展示和用户交互
- `shared/*` 放跨入口共享的数据结构和配置解析
- `content/market/auth-gate.ts` 只做登录态门控,不直接写登录流程
## 7. 配置设计
当前版本只定义占位配置,不写死真实值。
建议新增 `src/shared/auth-config.ts`,集中管理以下配置:
```ts
export interface AuthConfig {
logtoEndpoint: string;
appId: string;
apiResource: string;
scopes: string[];
enableDevAuthPanel: boolean;
}
```
建议占位字段:
- `logtoEndpoint`: `https://<your-tenant>.logto.app`
- `appId`: `<chrome-extension-app-id>`
- `apiResource`: `<your-global-api-resource-indicator>`
- `scopes`: `["openid", "profile", "offline_access"]`
- `enableDevAuthPanel`: `false`
配置要求:
- 缺失配置时必须明确报错
- 不允许 silent fail
- 后续真实接入时应能通过单点改动切换环境
## 8. Manifest 变更要求
现有 manifest 仅覆盖下载和内容脚本注入,接入 Logto 后至少需要补充:
- `permissions` 增加 `identity`
- `permissions` 保留或补充 `storage`
- 新增 `action.default_popup`
- 根据实际请求域名增加必要的 `host_permissions`
示意要求:
```json
{
"permissions": ["downloads", "identity", "storage"],
"action": {
"default_popup": "popup/index.html"
}
}
```
实际值需要结合构建输出路径调整,但规范上必须支持 popup 与 identity 登录流程。
## 9. 认证与会话数据流
### 9.1 登录流程
1. 用户点击扩展图标,打开 `popup`
2. `popup` 读取 background 返回的 `auth state`
3. 如果未登录,点击“登录 Logto”
4. `popup` 发送 `auth:sign-in` 给 background
5. background 发起 Logto 登录流程
6. 登录成功后,background 更新本地认证摘要状态
7. `popup` 重新读取状态并渲染已登录信息
### 9.2 内容脚本门控流程
1. 内容脚本进入星图市场页
2. 插件初始化前先发送 `auth:get-state`
3. 如果未登录,渲染禁用态,不执行当前业务能力初始化
4. 如果已登录,继续执行现有注入逻辑
### 9.3 登出流程
1. 用户在 `popup` 点击退出登录
2. `popup` 发送 `auth:sign-out`
3. background 清理会话
4. `popup` 回到未登录态
5. 已打开的市场页在刷新后进入禁用态
### 9.4 token 策略
当前版本采用:
- Authorization Code Flow + PKCE
- 基础 scopes`openid profile offline_access`
- `resource` 对应未来业务后端 API 的 global API resource
约束:
- `ID token` 仅用于身份展示
- `Access token` 仅用于未来业务后端 API 调用
- 内容脚本只按需向 background 请求 token,不本地长期存储
## 10. 消息协议建议
建议新增一组显式消息类型:
```ts
type AuthMessage =
| { type: "auth:get-state" }
| { type: "auth:sign-in" }
| { type: "auth:sign-out" }
| { type: "auth:get-access-token" };
```
建议的状态结构:
```ts
interface AuthState {
isAuthenticated: boolean;
userInfo: {
sub: string;
name?: string;
username?: string;
email?: string;
} | null;
scopes: string[];
resource: string | null;
accessTokenExpiresAt: number | null;
lastError: string | null;
}
```
设计要求:
- 状态必须可序列化
- 消息响应必须有稳定结构
- 失败时返回明确错误码或错误文案
## 11. Popup 交互设计
### 11.1 未登录态
显示内容:
- 插件名称
- 说明文案:“登录后才能使用星图增强功能”
- 主按钮:“登录 Logto”
- 最近一次登录失败摘要(如果存在)
### 11.2 已登录态
默认展示:
- 已登录状态
- 用户名
- 邮箱
- `退出登录` 按钮
### 11.3 开发调试态
仅在 `enableDevAuthPanel` 开启时显示:
- `isAuthenticated`
- `sub`
- `scopes`
- `resource`
- token 是否可获取
- access token 预计过期时间
- 最近一次认证错误
- 手动“刷新状态”按钮
默认不显示完整 token 字符串。
## 12. 内容脚本权限门控设计
### 12.1 未登录态
插件工具栏应替换为禁用面板,显示:
- 主提示:“请先登录插件”
- 次提示:“打开扩展弹窗完成登录后刷新本页”
- 引导按钮:“去登录”
“去登录”按钮行为:
- 不直接尝试从内容脚本打开 popup
- 仅提示用户点击浏览器工具栏中的扩展图标完成登录
未登录时:
- 不初始化筛选
- 不初始化排序
- 不初始化导出
- 不发起后续业务请求
### 12.2 已登录态
保持当前市场增强行为不变。
### 12.3 登录失效态
如果内容脚本运行过程中发现 background 返回未登录或 token 不可用:
- 立即切回禁用态
- 提示“登录已失效,请重新登录”
## 13. 错误处理设计
至少覆盖以下错误:
- Logto 配置缺失
- 登录取消
- 登录回调失败
- token 获取失败
- popup 无法读取 background 状态
- 内容脚本无法获取登录态
设计要求:
- popup 中展示用户可理解的错误摘要
- background 中保留标准化错误结构
- 内容脚本遇到认证错误时优先进入禁用态,而不是继续执行业务逻辑
## 14. TDD 实施要求
实现必须采用 TDD,遵循:
1. 先写失败测试
2. 明确验证测试因缺失功能而失败
3. 再写最小实现让测试通过
4. 最后做必要重构
不允许先写生产代码再补测试。
## 15. 测试分层建议
### 15.1 单元测试:消息协议与状态结构
建议文件:
- `tests/auth-messages.test.ts`
- `tests/auth-state-store.test.ts`
覆盖点:
- 消息类型收发正确
- 状态序列化结构稳定
- 配置缺失时能返回明确错误
### 15.2 单元测试:background 认证协调器
建议文件:
- `tests/background-auth-controller.test.ts`
覆盖点:
- `signIn` 调用认证入口
- `signOut` 清理会话
- `getAuthState` 返回认证摘要
- `getAccessToken` 在未登录或配置无效时失败明确
### 15.3 单元测试:popup UI
建议文件:
- `tests/popup-entry.test.ts`
覆盖点:
- 未登录显示登录按钮
- 已登录显示用户信息和退出按钮
- 开发模式显示调试区
- 错误态显示错误摘要
### 15.4 集成测试:内容脚本门控
建议文件:
- `tests/market-auth-gating.test.ts`
覆盖点:
- 未登录时市场页工具栏进入禁用态
- 已登录后才初始化现有筛选、排序、导出
- 运行中登录失效时切回禁用态
## 16. 验收标准
- 扩展加载后存在可用的 `popup`
- 未登录时 `popup` 显示登录入口
- 登录成功后 `popup` 显示基础用户信息
- 开发模式下 `popup` 能显示认证调试信息
- 未登录时市场页插件整体禁用
- 登录后刷新市场页,现有插件能力恢复
- 登出后刷新市场页,插件重新禁用
- 配置缺失时,popup / background / content 都能给出明确错误,而不是静默失败
## 17. 后续实现顺序建议
建议实施顺序:
1. 补 manifest 与构建入口,先让 popup 可以独立加载
2.`auth-messages``auth-config` 的失败测试
3. 写 background auth controller 的失败测试并落最小实现
4. 写 popup 状态渲染测试并接上 background
5. 写内容脚本门控测试,再把市场页逻辑接入认证态
6. 最后补开发调试面板
## 18. 当前仍保留为占位的实施参数
这些值在真正开始编码前仍需由你提供:
- Logto tenant endpoint
- Logto Chrome extension appId
- Global API resource 标识
- 是否存在额外自定义 scopes
- 调试面板是否区分开发包与生产包
在这些真实值到位前,可以先完成目录搭建、消息协议、状态门控和测试骨架。
+2221 -543
View File
File diff suppressed because it is too large Load Diff
+22 -8
View File
@@ -1,16 +1,30 @@
{
"name": "star-chart-search-enhancer",
"name": "market-plugin-impl",
"version": "0.2.0421.2",
"description": "Bootstrap for the Xingtu market Chrome MV3 extension.",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"test:watch": "vitest"
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
"mock:protected-api": "node scripts/mock-protected-api.mjs",
"release:tag": "node scripts/ci/release-tag.mjs",
"package:internal": "npm run build:release && node scripts/package-release.mjs",
"package:release": "npm run build:release && node scripts/package-release.mjs",
"write:latest": "node scripts/write-latest-manifest.mjs",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests"
},
"dependencies": {
"@logto/chrome-extension": "^0.1.27",
"yazl": "^3.3.1"
},
"license": "UNLICENSED",
"devDependencies": {
"jsdom": "^26.1.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
"@playwright/test": "^1.59.1",
"cos-nodejs-sdk-v5": "^2.15.4",
"jsdom": "^29.0.2",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.4"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"guideUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.4/星图增强插件-超简单安装使用指南.pdf",
"latestVersion": "0.0525.4",
"minSupportedVersion": "0.0525.4",
"publishedAt": "2026-05-25",
"releaseNotes": [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
"zipUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.4/star-chart-search-enhancer-internal.zip"
}
Binary file not shown.
+72 -17
View File
@@ -1,33 +1,88 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "tsup";
import { createManifest } from "./manifest.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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, { force: true, recursive: true });
await rm(distDir, { recursive: true, force: true });
await mkdir(path.join(distDir, "content"), { recursive: true });
await mkdir(path.join(distDir, "background"), { recursive: true });
await mkdir(path.join(distDir, "popup"), { recursive: true });
await build({
clean: false,
dts: false,
entry: {
"content/index": path.join(projectRoot, "src/content/index.ts"),
"page/hook": path.join(projectRoot, "src/page/hook.ts")
index: path.join(projectRoot, "src/content/index.ts"),
"market-page-bridge": path.join(
projectRoot,
"src/content/market/page-bridge.ts"
)
},
format: ["iife"],
minify: false,
outDir: distDir,
platform: "browser",
silent: true,
target: "chrome114",
outDir: path.join(distDir, "content"),
clean: false,
splitting: false,
target: "es2022",
treeshake: false
sourcemap: false,
minify: false,
outExtension() {
return { js: ".js" };
}
});
await mkdir(distDir, { recursive: true });
await copyFile(
path.join(projectRoot, "src/manifest.json"),
path.join(distDir, "manifest.json")
await build({
entry: {
index: path.join(projectRoot, "src/background/index.ts")
},
format: ["iife"],
platform: "browser",
target: "chrome114",
outDir: path.join(distDir, "background"),
clean: false,
splitting: false,
sourcemap: false,
minify: false,
outExtension() {
return { js: ".js" };
}
});
await build({
entry: {
index: path.join(projectRoot, "src/popup/index.ts")
},
format: ["iife"],
platform: "browser",
target: "chrome114",
outDir: path.join(distDir, "popup"),
clean: false,
splitting: false,
sourcemap: false,
minify: false,
outExtension() {
return { js: ".js" };
}
});
await writeFile(
path.join(distDir, "manifest.json"),
`${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n`
);
await cp(
path.join(projectRoot, "src/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
};
}
+117
View File
@@ -0,0 +1,117 @@
import http from "node:http";
export function createMockProtectedApiServer({ port = 4319 } = {}) {
let server;
return {
get baseUrl() {
const address = server?.address();
const resolvedPort =
typeof address === "object" && address ? address.port : port;
return `http://127.0.0.1:${resolvedPort}`;
},
async start() {
server = http.createServer(async (request, response) => {
if (request.url === "/api/mock/protected") {
const authHeader = readBearerToken(request, response);
if (!authHeader) {
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
source: "mock-protected-api",
message: "authorized",
receivedAuthHeader: authHeader
})
);
return;
}
if (request.url === "/api/mock/batches" && request.method === "POST") {
const authHeader = readBearerToken(request, response);
if (!authHeader) {
return;
}
const payload = await readJsonBody(request);
const authors = Array.isArray(payload?.authors) ? payload.authors : [];
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
source: "mock-batch-submit",
acceptedCount: authors.length,
batchId:
typeof payload?.batchId === "string" ? payload.batchId : null,
receivedAuthHeader: authHeader
})
);
return;
}
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "not-found" }));
});
await new Promise((resolve) => {
server.listen(port, "127.0.0.1", resolve);
});
},
async close() {
if (!server) {
return;
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(undefined);
});
});
}
};
}
function readBearerToken(request, response) {
const authHeader = request.headers.authorization ?? "";
const isBearer =
typeof authHeader === "string" &&
authHeader.startsWith("Bearer ") &&
authHeader.length > "Bearer ".length;
if (!isBearer) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
return null;
}
return authHeader;
}
async function readJsonBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
if (chunks.length === 0) {
return null;
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
if (import.meta.url === `file://${process.argv[1]}`) {
const server = createMockProtectedApiServer();
await server.start();
console.log(`mock protected api listening on ${server.baseUrl}`);
}
+33
View File
@@ -0,0 +1,33 @@
import { createWriteStream } from "node:fs";
import { readdir } from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import yazl from "yazl";
export async function createReleaseArchive({ archivePath, sourceDir }) {
const zip = new yazl.ZipFile();
const output = createWriteStream(archivePath);
await addDirectory(zip, sourceDir, sourceDir);
zip.end();
await pipeline(zip.outputStream, output);
}
async function addDirectory(zip, rootDir, currentDir) {
const entries = await readdir(currentDir, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, absolutePath);
if (entry.isDirectory()) {
await addDirectory(zip, rootDir, absolutePath);
continue;
}
if (entry.isFile()) {
zip.addFile(absolutePath, relativePath);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createReleaseArchive } from "./package-release-archive.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourceDir = path.join(projectRoot, "dist-release");
const 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 createReleaseArchive({
archivePath,
sourceDir
});
console.log(`Internal archive created at ${archivePath}`);
+25
View File
@@ -0,0 +1,25 @@
import path from "node:path";
export function buildReleaseUploadTargets({
projectRoot,
releaseVersion
}) {
const releaseDir = path.join(projectRoot, "release");
const releasePrefix = "star-chart-search-enhancer";
const releaseVersionPrefix = `${releasePrefix}/releases/${releaseVersion}`;
return [
{
cosKey: `${releasePrefix}/latest.json`,
localPath: path.join(releaseDir, "latest.json")
},
{
cosKey: `${releaseVersionPrefix}/star-chart-search-enhancer-internal.zip`,
localPath: path.join(releaseDir, "star-chart-search-enhancer-internal.zip")
},
{
cosKey: `${releaseVersionPrefix}/星图增强插件-超简单安装使用指南.pdf`,
localPath: path.join(releaseDir, "星图增强插件-超简单安装使用指南.pdf")
}
];
}
+30
View File
@@ -0,0 +1,30 @@
const RELEASE_VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
export function normalizeReleaseVersionTag(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().replace(/^v/i, "");
if (!RELEASE_VERSION_PATTERN.test(normalized)) {
return null;
}
return normalized;
}
export function resolveReleaseVersion(
env = process.env,
fallbackVersion = "0.2.0421.2"
) {
const candidates = [env.EXTENSION_VERSION, env.DRONE_TAG, fallbackVersion];
for (const candidate of candidates) {
const normalized = normalizeReleaseVersionTag(candidate);
if (normalized) {
return normalized;
}
}
throw new Error("unable to resolve a valid release version");
}
+15
View File
@@ -0,0 +1,15 @@
export function createLatestManifest(options) {
const publishedAt = options.publishedAt ?? new Date().toISOString().slice(0, 10);
return {
guideUrl: `${options.publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`,
latestVersion: options.latestVersion,
minSupportedVersion: options.minSupportedVersion,
publishedAt,
releaseNotes: [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
zipUrl: `${options.publicBaseUrl}/star-chart-search-enhancer-internal.zip`
};
}
+31
View File
@@ -0,0 +1,31 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createManifest } from "./manifest.mjs";
import { createLatestManifest } from "./write-latest-manifest-data.mjs";
import { resolveReleaseVersion } from "./release-version.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const releaseDir = path.join(projectRoot, "release");
const releaseManifest = createManifest({ target: "release" });
const latestVersion =
process.env.LATEST_VERSION ?? resolveReleaseVersion(process.env, releaseManifest.version);
const publicBaseUrl =
process.env.UPDATE_PUBLIC_BASE_URL ??
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${latestVersion}`;
const latestManifest = createLatestManifest({
latestVersion,
minSupportedVersion: releaseManifest.version,
publicBaseUrl
});
await mkdir(releaseDir, { recursive: true });
await writeFile(
path.join(releaseDir, "latest.json"),
`${JSON.stringify(latestManifest, null, 2)}\n`,
"utf8"
);
console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`);
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

+54
View File
@@ -0,0 +1,54 @@
import LogtoClient from "@logto/chrome-extension";
import { readAuthConfig } from "../../shared/auth-config";
import type { AuthClientLike } from "./types";
export function createLogtoAuthClient(): AuthClientLike {
const config = readAuthConfig();
const client = new LogtoClient({
appId: config.appId,
endpoint: config.logtoEndpoint,
resources: [config.apiResource],
scopes: config.scopes
});
return {
getAccessToken(resource?: string) {
return client.getAccessToken(resource);
},
getIdTokenClaims() {
return client.getIdTokenClaims();
},
isAuthenticated() {
return client.isAuthenticated();
},
signIn() {
return client.signIn(readChromeIdentity().getRedirectURL("/callback"));
},
signOut() {
return client.signOut(readChromeIdentity().getRedirectURL());
}
};
}
function readChromeIdentity(): {
getRedirectURL: (path?: string) => string;
} {
const identity = (
globalThis as typeof globalThis & {
chrome?: {
identity?: {
getRedirectURL?: (path?: string) => string;
};
};
}
).chrome?.identity;
if (typeof identity?.getRedirectURL !== "function") {
throw new Error("chrome.identity.getRedirectURL is unavailable");
}
return {
getRedirectURL: identity.getRedirectURL.bind(identity)
};
}
+48
View File
@@ -0,0 +1,48 @@
import { readAuthConfig, type AuthConfig } from "../../shared/auth-config";
import { createLoggedInAuthState, createLoggedOutAuthState } from "./state";
import type { AuthClientLike } from "./types";
export interface AuthController {
getAccessToken(): Promise<string>;
getAuthState(): Promise<ReturnType<typeof createLoggedOutAuthState>>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
export function createAuthController(options: {
authClient: AuthClientLike;
config?: AuthConfig;
}): AuthController {
const config = options.config ?? readAuthConfig();
return {
async getAccessToken() {
return options.authClient.getAccessToken(config.apiResource);
},
async getAuthState() {
const isAuthenticated = await options.authClient.isAuthenticated();
if (!isAuthenticated) {
return createLoggedOutAuthState(config);
}
try {
await options.authClient.getAccessToken(config.apiResource);
} catch (error) {
return createLoggedOutAuthState(
config,
error instanceof Error ? error.message : String(error)
);
}
const claims = await options.authClient.getIdTokenClaims();
return createLoggedInAuthState(claims, config);
},
async signIn() {
await options.authClient.signIn();
},
async signOut() {
await options.authClient.signOut();
}
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { AuthConfig } from "../../shared/auth-config";
import type { AuthStateValue } from "../../shared/auth-messages";
export function createLoggedOutAuthState(
config?: Pick<AuthConfig, "apiResource">,
lastError?: string | null
): AuthStateValue {
return {
isAuthenticated: false,
lastError: lastError ?? null,
resource: config?.apiResource ?? null
};
}
export function createLoggedInAuthState(
claims: Record<string, unknown> | null | undefined,
config?: Pick<AuthConfig, "apiResource" | "scopes">
): AuthStateValue {
return {
accessTokenExpiresAt: null,
isAuthenticated: true,
resource: config?.apiResource ?? null,
scopes: config?.scopes ?? [],
tokenAvailable: true,
userInfo: {
email: readStringClaim(claims, "email"),
name: readStringClaim(claims, "name"),
sub: readStringClaim(claims, "sub"),
username: readStringClaim(claims, "username")
}
};
}
function readStringClaim(
claims: Record<string, unknown> | null | undefined,
key: string
): string | undefined {
const value = claims?.[key];
return typeof value === "string" ? value : undefined;
}
+7
View File
@@ -0,0 +1,7 @@
export interface AuthClientLike {
getAccessToken(resource?: string): Promise<string>;
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
isAuthenticated(): Promise<boolean>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
+306
View File
@@ -0,0 +1,306 @@
import { createAuthController, type AuthController } from "./auth/controller";
import { createLogtoAuthClient } from "./auth/client";
import {
isAuthRequestMessage,
type AuthResponseMessage
} from "../shared/auth-messages";
import { createBatchSubmitClient } from "../shared/batch-submit-client";
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../shared/batch-submit-config";
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
interface ChromeDownloadsLike {
download(
options: {
filename: string;
saveAs?: boolean;
url: string;
},
callback?: () => void
): Promise<unknown> | void;
}
interface ChromeRuntimeLike {
onMessage?: {
addListener(
listener: (
message: unknown,
sender: unknown,
sendResponse: (response: unknown) => void
) => boolean | void
): void;
};
}
interface ChromeLike {
downloads?: ChromeDownloadsLike;
runtime?: ChromeRuntimeLike;
}
type DownloadMarketCsvMessage = {
csv: string;
filename: string;
type: "download-market-csv";
};
type BatchSubmitMessage = {
payload: unknown;
type: "batch:submit";
};
type DownloadUpdateMessage = {
filename: string;
type: "update:download";
url: string;
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = readChromeLike(),
dependencies: {
authController?: AuthController;
searchBackendMetrics?: (starIds: string[]) => Promise<unknown>;
submitBatch?: (payload: unknown) => Promise<unknown>;
} = {}
): void {
let authController = dependencies.authController;
let searchBackendMetrics = dependencies.searchBackendMetrics;
let submitBatch = dependencies.submitBatch;
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (isDownloadMarketCsvMessage(message)) {
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
});
return true;
}
if (isDownloadUpdateMessage(message)) {
void triggerUpdateDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true, type: "update:download-ack" });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "update:download-error"
});
});
return true;
}
if (isBatchSubmitMessage(message)) {
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
submitBatch ??= createBatchSubmitClient({
baseUrl: DEFAULT_BATCH_SUBMIT_BASE_URL,
getAccessToken: () => authController!.getAccessToken(),
sendMessage: () =>
Promise.reject(new Error("background batch submit does not use sendMessage"))
}).submitBatch;
void submitBatch(message.payload)
.then((value) => {
sendResponse({
ok: true,
type: "batch:ack",
value
});
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "batch:error"
});
});
return true;
}
if (isBackendMetricsSearchRequestMessage(message)) {
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
searchBackendMetrics ??= createBackendMetricsClient({
baseUrl: DEFAULT_BACKEND_METRICS_BASE_URL,
getAccessToken: () => authController!.getAccessToken()
}).searchByStarIds;
void searchBackendMetrics(message.value.starIds)
.then((rows) => {
sendResponse({
ok: true,
type: "backend-metrics:result",
value: {
rows
}
});
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "backend-metrics:error"
});
});
return true;
}
if (!isAuthRequestMessage(message)) {
return;
}
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
void handleAuthMessage(authController, message)
.then((response) => {
sendResponse(response);
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "auth:error"
} satisfies AuthResponseMessage);
});
return true;
});
}
async function triggerUpdateDownload(
chromeLike: ChromeLike,
message: DownloadUpdateMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: true,
url: message.url
})
);
}
async function handleAuthMessage(
authController: AuthController,
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
): Promise<AuthResponseMessage> {
if (message.type === "auth:get-state") {
return {
ok: true,
type: "auth:state",
value: await authController.getAuthState()
};
}
if (message.type === "auth:get-access-token") {
return {
ok: true,
type: "auth:token",
value: {
accessToken: await authController.getAccessToken()
}
};
}
if (message.type === "auth:sign-in") {
await authController.signIn();
return {
ok: true,
type: "auth:ack"
};
}
await authController.signOut();
return {
ok: true,
type: "auth:ack"
};
}
function readChromeLike(): ChromeLike {
return (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {};
}
async function triggerCsvDownload(
chromeLike: ChromeLike,
message: DownloadMarketCsvMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: false,
url: csvUrl
})
);
}
function isDownloadMarketCsvMessage(
message: unknown
): message is DownloadMarketCsvMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadMarketCsvMessage>;
return (
candidate.type === "download-market-csv" &&
typeof candidate.csv === "string" &&
typeof candidate.filename === "string"
);
}
function isDownloadUpdateMessage(
message: unknown
): message is DownloadUpdateMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadUpdateMessage>;
return (
candidate.type === "update:download" &&
typeof candidate.filename === "string" &&
typeof candidate.url === "string" &&
candidate.url.startsWith("https://")
);
}
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<BatchSubmitMessage>;
return candidate.type === "batch:submit" && "payload" in candidate;
}
registerBackgroundMessageHandler();
-181
View File
@@ -1,181 +0,0 @@
import { createRouteState } from "../route-state";
import {
isCandidateAnalysisMessage,
isCandidateRequestMessage,
isHookReadyMessage,
isAfterSearchRateResultMessage
} from "../../shared/message-types";
import type { AfterSearchRateResult } from "../../shared/result-types";
const LOG_PREFIX = "[star-chart-search-enhancer]";
const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook";
interface ChromeRuntimeLike {
getURL(path: string): string;
}
interface LoggerLike {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
}
export interface DetailContentControllerOptions {
chromeRuntime: ChromeRuntimeLike;
document: Document;
logger: LoggerLike;
window: Window;
}
export function createDetailContentController(
options: DetailContentControllerOptions
) {
const routeState = createRouteState(options.window.location.href);
let currentSnapshot = routeState.getSnapshot();
const loggedResults = new Map<string, { fingerprint: string; success: boolean }>();
const originalPushState = options.window.history.pushState.bind(
options.window.history
);
const originalReplaceState = options.window.history.replaceState.bind(
options.window.history
);
const onMessage = (event: MessageEvent) => {
if (event.source !== options.window) {
return;
}
if (isHookReadyMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-hook-ready",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateRequestMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-request", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-request",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateAnalysisMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-analysis", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-analysis",
event.data.payload.routeKey
);
}
return;
}
if (!isAfterSearchRateResultMessage(event.data)) {
return;
}
const payload = event.data.payload;
if (!isSameRouteIdentity(payload.routeKey, currentSnapshot.routeKey)) {
options.logger.debug(LOG_PREFIX, "stale-result", payload.routeKey);
return;
}
logFinalResult(payload);
};
options.window.addEventListener("message", onMessage);
options.window.history.pushState = wrapHistoryMethod(originalPushState);
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState);
options.window.addEventListener("popstate", handleNavigation);
injectPageHook(options.document, options.chromeRuntime);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
return {
dispose() {
options.window.removeEventListener("message", onMessage);
options.window.removeEventListener("popstate", handleNavigation);
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
},
getSnapshot() {
return currentSnapshot;
}
};
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
currentSnapshot = routeState.advance(options.window.location.href);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
}
function logFinalResult(payload: AfterSearchRateResult) {
const fingerprint = JSON.stringify({
matchedRequestUrl: payload.matchedRequestUrl ?? null,
rates: payload.rates ?? null,
reason: payload.reason ?? null,
routeKey: payload.routeKey,
stage: payload.stage,
success: payload.success
});
const previousResult = loggedResults.get(payload.routeKey);
if (previousResult?.fingerprint === fingerprint) {
return;
}
if (previousResult?.success && !payload.success) {
return;
}
loggedResults.set(payload.routeKey, {
fingerprint,
success: payload.success
});
options.logger.info(LOG_PREFIX, "result", payload);
}
}
function isSameRouteIdentity(leftRouteKey: string, rightRouteKey: string): boolean {
return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey);
}
function stripNavigationSeq(routeKey: string): string {
return routeKey.replace(/::\d+$/, "");
}
function injectPageHook(document: Document, chromeRuntime: ChromeRuntimeLike) {
if (document.getElementById(PAGE_HOOK_SCRIPT_ID)) {
return;
}
const script = document.createElement("script");
script.id = PAGE_HOOK_SCRIPT_ID;
script.src = chromeRuntime.getURL("page/hook.global.js");
script.async = false;
(document.head ?? document.documentElement).appendChild(script);
}
+190 -53
View File
@@ -1,67 +1,96 @@
import { getStarIdFromUrl } from "../shared/get-star-id";
import { RESULT_MESSAGE_TYPE } from "../shared/message-types";
import {
createDetailContentController,
type DetailContentControllerOptions
} from "./detail/index";
import {
createMarketContentController,
type MarketContentControllerOptions
createMarketController,
type CreateMarketControllerOptions
} from "./market/index";
interface ControllerLike {
dispose(): void;
}
import { renderMarketAuthGate } from "./market/auth-gate";
import {
isAuthResponseMessage,
type AuthStateValue
} from "../shared/auth-messages";
interface ChromeRuntimeLike {
getURL(path: string): string;
getURL?: (path: string) => string;
id?: string;
sendMessage?: (message: unknown) => void | Promise<unknown>;
}
interface LoggerLike {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv";
interface BootContentScriptOptions {
createMarketController?: (
options: CreateMarketControllerOptions
) => { dispose?: () => void; ready: Promise<void> };
document?: Document;
sendAuthMessage?: (message: unknown) => Promise<unknown>;
window?: Window;
}
interface ContentControllerOptions extends DetailContentControllerOptions {
detailControllerFactory?: (
options: DetailContentControllerOptions
) => ControllerLike;
marketControllerFactory?: (
options: MarketContentControllerOptions
) => ControllerLike;
}
export async function bootContentScript(
options: BootContentScriptOptions = {}
): Promise<{ ready: Promise<void> } | null> {
const currentWindow = options.window ?? window;
const currentDocument = options.document ?? document;
const controllerFactory =
options.createMarketController ?? createMarketController;
const sendAuthMessage =
options.sendAuthMessage ?? createRuntimeMessageSender();
export function createContentController(
options: ContentControllerOptions
): ControllerLike {
if (isCreatorDetailUrl(options.window.location.href)) {
const detailControllerFactory =
options.detailControllerFactory ?? createDetailContentController;
return detailControllerFactory(options);
if (!isMarketPage(currentWindow.location.href)) {
return null;
}
if (isCreatorMarketUrl(options.window.location.href)) {
const marketControllerFactory =
options.marketControllerFactory ?? createMarketContentController;
return marketControllerFactory(options);
installMarketPageBridge(currentDocument);
const authState = await readAuthState(sendAuthMessage);
if (!authState?.isAuthenticated) {
await waitForBodyReady(currentDocument, currentWindow);
renderMarketAuthGate(
currentDocument,
currentWindow,
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
);
return {
ready: Promise.resolve()
};
}
return createNoopController();
await waitForBodyReady(currentDocument, currentWindow);
return controllerFactory({
document: currentDocument,
onCsvReady: (csv: string, filename?: string) => {
if (filename) {
downloadCsv(currentDocument, currentWindow, csv, filename);
return;
}
if (requestCsvDownload(csv)) {
return;
}
downloadCsv(currentDocument, currentWindow, csv, filename);
},
window: currentWindow
});
}
function isCreatorDetailUrl(url: string): boolean {
return getStarIdFromUrl(url) !== null;
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
return null;
}
return response.value;
}
function createNoopController(): ControllerLike {
return {
dispose() {}
};
}
function isMarketPage(url: string): boolean {
const parsedUrl = new URL(url);
const isXingtuHost =
parsedUrl.hostname === "xingtu.cn" || parsedUrl.hostname.endsWith(".xingtu.cn");
function isCreatorMarketUrl(url: string): boolean {
return new URL(url).pathname === "/ad/creator/market";
return isXingtuHost && parsedUrl.pathname.startsWith("/ad/creator/market");
}
function bootstrapContentScript() {
@@ -77,21 +106,129 @@ function bootstrapContentScript() {
const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & {
[marker]?: ControllerLike;
[marker]?: boolean | { dispose?: () => void; ready: Promise<void> } | null;
};
if (scopedWindow[marker]) {
return;
}
scopedWindow[marker] = createContentController({
chromeRuntime: runtime,
document,
logger: console,
window
scopedWindow[marker] = true;
void bootContentScript().then((controller) => {
scopedWindow[marker] = controller;
});
}
bootstrapContentScript();
export { RESULT_MESSAGE_TYPE };
function requestCsvDownload(csv: string, filename?: string): boolean {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime?.id || typeof runtime.sendMessage !== "function") {
return false;
}
runtime.sendMessage({
csv,
filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
type: DOWNLOAD_MARKET_CSV_MESSAGE
});
return true;
}
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
return async (message: unknown) => {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (typeof runtime?.sendMessage !== "function") {
return null;
}
return runtime.sendMessage(message);
};
}
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], {
type: "text/csv;charset=utf-8"
});
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
}
function formatTimestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function isExpiredAuthState(authState: AuthStateValue | null): boolean {
const lastError = authState?.lastError;
return (
typeof lastError === "string" &&
(/token/i.test(lastError) || lastError.includes("过期"))
);
}
function installMarketPageBridge(document: Document) {
if (
document.documentElement.querySelector(
'[data-sces-market-bridge="script"]'
)
) {
return;
}
const script = document.createElement("script");
script.dataset.scesMarketBridge = "script";
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
const bridgeUrl = runtime?.getURL?.("content/market-page-bridge.js");
if (bridgeUrl) {
script.src = bridgeUrl;
} else {
script.textContent = "";
}
(document.head ?? document.documentElement).appendChild(script);
}
+70 -66
View File
@@ -1,23 +1,10 @@
import { normalizeRateValue } from "../../shared/normalize-rate-value";
import type { AfterSearchRates } from "../../shared/result-types";
type MarketApiFailureReason = "bad-response" | "request-failed" | "timeout";
type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
};
type MarketApiFailureResult = {
reason: MarketApiFailureReason;
success: false;
};
export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult;
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import type { MarketApiResult } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status?: number;
}
type FetchLike = (
@@ -38,44 +25,52 @@ export function createMarketApiClient(options: MarketApiClientOptions = {}) {
return {
async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAuthorAseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
if (!response.ok) {
return {
reason: "request-failed",
success: false
};
}
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
reason: "timeout",
success: false
};
}
return {
reason: "request-failed",
success: false
};
} finally {
clearTimeout(timeoutId);
const primaryResult = await loadAuthorMetricsFromUrl(
buildAuthorCommerceSeedBaseInfoUrl(authorId, baseUrl)
);
if (primaryResult.success || primaryResult.reason === "timeout") {
return primaryResult;
}
return loadAuthorMetricsFromUrl(buildAuthorAseInfoUrl(authorId, baseUrl));
}
};
async function loadAuthorMetricsFromUrl(url: string): Promise<MarketApiResult> {
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 {
success: false,
reason: "request-failed"
};
}
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
success: false,
reason: "timeout"
};
}
return {
success: false,
reason: "request-failed"
};
} finally {
clearTimeout(timeoutId);
}
}
}
export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string {
@@ -85,12 +80,25 @@ export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string
return url.toString();
}
export function buildAuthorCommerceSeedBaseInfoUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_seed_base_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("range", "90");
return url.toString();
}
export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
const data = getPayloadData(payload);
if (!data) {
return {
reason: "bad-response",
success: false
success: false,
reason: "bad-response"
};
}
@@ -101,19 +109,19 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
data.personal_avg_search_after_view_rate
);
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) {
return {
reason: "bad-response",
success: false
success: false,
reason: "missing-rate"
};
}
return {
success: true,
rates: {
personalVideoAfterSearchRate,
singleVideoAfterSearchRate
},
success: true
...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}),
...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {})
}
};
}
@@ -122,15 +130,11 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
return null;
}
if (isRecord(payload.data)) {
return payload.data;
}
return payload;
return isRecord(payload.data) ? payload.data : payload;
}
function readNormalizedRate(value: unknown): string | null {
return typeof value === "string" ? normalizeRateValue(value) : null;
return typeof value === "string" ? normalizeRateDisplay(value) : null;
}
function resolveBaseUrl(): string {
@@ -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;
+34
View File
@@ -0,0 +1,34 @@
export function renderMarketAuthGate(
document: Document,
currentWindow: Window,
message = "请先登录插件"
): HTMLElement {
const existingGate = document.querySelector(
'[data-market-auth-gate="root"]'
) as HTMLElement | null;
if (existingGate) {
return existingGate;
}
const root = document.createElement("section");
root.dataset.marketAuthGate = "root";
root.innerHTML = `
<strong></strong>
<p></p>
<button type="button" data-market-auth-help="button"></button>
`;
const title = root.querySelector("strong");
if (title) {
title.textContent = message;
}
root
.querySelector('[data-market-auth-help="button"]')
?.addEventListener("click", () => {
currentWindow.alert("请点击浏览器工具栏中的扩展图标完成登录");
});
document.body.prepend(root);
return root;
}
+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
};
}
-206
View File
@@ -1,206 +0,0 @@
import type { MarketApiResult } from "./api-client";
import { createMarketCacheStore } from "./cache-store";
import type { RowErrorReason, MarketRowState } from "./row-state";
import type { RequiredAfterSearchRates } from "./types";
interface BatchLoaderRow {
authorId: string | null;
rowKey: string;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
}
interface LoadRowsOptions {
listSeq: number;
rows: BatchLoaderRow[];
shouldRenderResult?: (params: {
authorId: string;
listSeq: number;
row: BatchLoaderRow;
}) => boolean;
}
interface MarketBatchLoaderOptions {
apiClient: {
loadAuthorAseInfo(authorId: string): Promise<MarketApiResult>;
};
cacheStore?: ReturnType<typeof createMarketCacheStore>;
concurrency?: number;
}
export function createMarketBatchLoader(options: MarketBatchLoaderOptions) {
const cacheStore = options.cacheStore ?? createMarketCacheStore();
const concurrency = Math.max(options.concurrency ?? 4, 1);
return {
async loadRows(loadOptions: LoadRowsOptions) {
const groupedRows = new Map<string, BatchLoaderRow[]>();
for (const row of loadOptions.rows) {
if (!row.authorId) {
row.render({
authorId: null,
listSeq: loadOptions.listSeq,
reason: "missing-author-id",
retryable: false,
state: "error"
});
continue;
}
const cached = cacheStore.getSuccess(row.authorId);
if (cached) {
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
personalVideoAfterSearchRate: cached.rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: cached.rates.singleVideoAfterSearchRate,
source: "cache",
state: "success"
});
continue;
}
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
state: "loading"
});
const rowsForAuthor = groupedRows.get(row.authorId) ?? [];
rowsForAuthor.push(row);
groupedRows.set(row.authorId, rowsForAuthor);
}
const tasks = Array.from(groupedRows.entries(), ([authorId, rows]) => {
return () => loadAuthorRows(authorId, rows, loadOptions);
});
await runWithConcurrency(tasks, concurrency);
}
};
async function loadAuthorRows(
authorId: string,
rows: BatchLoaderRow[],
loadOptions: LoadRowsOptions
) {
const result = await requestAuthor(authorId);
for (const row of rows) {
if (
loadOptions.shouldRenderResult &&
!loadOptions.shouldRenderResult({
authorId,
listSeq: loadOptions.listSeq,
row
})
) {
continue;
}
renderAuthorResult(row, authorId, loadOptions.listSeq, result, false);
}
}
async function retryRow(row: BatchLoaderRow, listSeq: number) {
if (!row.authorId) {
return;
}
row.render({
authorId: row.authorId,
listSeq,
state: "loading"
});
const result = await requestAuthor(row.authorId);
renderAuthorResult(row, row.authorId, listSeq, result, false);
}
async function requestAuthor(authorId: string): Promise<MarketApiResult> {
const cached = cacheStore.getSuccess(authorId);
if (cached) {
return {
rates: cached.rates,
success: true
};
}
const inflight = cacheStore.getInflight<MarketApiResult>(authorId);
if (inflight) {
return inflight;
}
const requestPromise = options.apiClient
.loadAuthorAseInfo(authorId)
.then((result) => {
if (result.success) {
cacheStore.setSuccess(authorId, result.rates);
}
return result;
})
.finally(() => {
cacheStore.clearInflight(authorId);
});
cacheStore.setInflight(authorId, requestPromise);
return requestPromise;
}
function renderAuthorResult(
row: BatchLoaderRow,
authorId: string,
listSeq: number,
result: MarketApiResult,
fromCache: boolean
) {
if (result.success) {
const rates = result.rates as RequiredAfterSearchRates;
row.render({
authorId,
listSeq,
personalVideoAfterSearchRate: rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: rates.singleVideoAfterSearchRate,
source: fromCache ? "cache" : "network",
state: "success"
});
return;
}
row.render(
{
authorId,
listSeq,
reason: result.reason as RowErrorReason,
retryable: true,
state: "error"
},
{
onRetry: () => retryRow(row, listSeq)
}
);
}
}
async function runWithConcurrency(
tasks: Array<() => Promise<void>>,
concurrency: number
) {
let nextTaskIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, tasks.length) },
async () => {
while (nextTaskIndex < tasks.length) {
const task = tasks[nextTaskIndex];
nextTaskIndex += 1;
await task();
}
}
);
await Promise.all(workers);
}
+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";
}
+53
View File
@@ -0,0 +1,53 @@
import type { AuthStateValue } from "../../shared/auth-messages";
import type { MarketRecord } from "./types";
export interface BatchPayload {
authors: Array<{
authorId: string;
authorName: string;
authorUid?: string;
}>;
batchName: string;
createdAt: string;
creatorName: string;
logtoUserId: string;
resource: string;
}
export function createBatchPayload(options: {
authState: AuthStateValue;
batchName: string;
createdAt: string;
records: MarketRecord[];
}): BatchPayload {
const logtoUserId = options.authState.userInfo?.sub?.trim();
if (!logtoUserId) {
throw new Error("batch submit user id unavailable");
}
const resource = options.authState.resource?.trim();
if (!resource) {
throw new Error("batch submit resource unavailable");
}
const batchName = options.batchName.trim();
if (!batchName) {
throw new Error("batch submit batch name is required");
}
return {
authors: options.records.map((record) => ({
authorId: record.authorId,
authorName: record.authorName,
...(record.coreUserId ? { authorUid: record.coreUserId } : {})
})),
batchName,
createdAt: options.createdAt,
creatorName:
options.authState.userInfo?.name ??
options.authState.userInfo?.username ??
logtoUserId,
logtoUserId,
resource
};
}
@@ -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;
}
-32
View File
@@ -1,32 +0,0 @@
import type { RequiredAfterSearchRates } from "./types";
interface SuccessCacheEntry {
rates: RequiredAfterSearchRates;
updatedAt: number;
}
export function createMarketCacheStore() {
const successCache = new Map<string, SuccessCacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
return {
clearInflight(authorId: string) {
inflightRequests.delete(authorId);
},
getInflight<T>(authorId: string) {
return inflightRequests.get(authorId) as Promise<T> | undefined;
},
getSuccess(authorId: string) {
return successCache.get(authorId);
},
setInflight<T>(authorId: string, promise: Promise<T>) {
inflightRequests.set(authorId, promise);
},
setSuccess(authorId: string, rates: RequiredAfterSearchRates) {
successCache.set(authorId, {
rates,
updatedAt: Date.now()
});
}
};
}
+124
View File
@@ -0,0 +1,124 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import { escapeCsvCell } from "../../shared/csv";
import type { MarketRecord } from "./types";
export type CsvColumn = {
header: string;
readValue: (record: MarketRecord) => string;
};
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
{
header: "达人ID",
readValue: (record: MarketRecord) => record.authorId
},
{
header: "达人名称",
readValue: (record: MarketRecord) => record.authorName
},
{
header: "地区",
readValue: (record: MarketRecord) => record.location ?? ""
},
{
header: "21-60s报价",
readValue: (record: MarketRecord) => record.price21To60s ?? ""
}
];
const RATE_COLUMNS: CsvColumn[] = [
{
header: "商单视频看后搜率",
readValue: (record: MarketRecord) =>
record.rates?.singleVideoAfterSearchRate
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
: ""
},
{
header: "个人视频看后搜率",
readValue: (record: MarketRecord) =>
record.rates?.personalVideoAfterSearchRate
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
: ""
}
];
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
{
header: "秒思api-看后搜率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchRate ?? ""
},
{
header: "秒思api-看后搜数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchCount ?? ""
},
{
header: "秒思api-新增A3数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.a3IncreaseCount ?? ""
},
{
header: "秒思api-新增A3率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.newA3Rate ?? ""
},
{
header: "秒思api-CPA3",
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
},
{
header: "秒思api-cp_search",
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
}
];
export function listRateCsvHeaders(): string[] {
return RATE_COLUMNS.map((column) => column.header);
}
export function listBackendMetricCsvHeaders(): string[] {
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
}
export function buildMarketCsv(records: MarketRecord[]): string {
const csvColumns = buildMarketCsvColumns(records);
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
);
return [headerLine, ...rowLines].join("\n");
}
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
const baseColumns = buildBaseColumns(records);
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
}
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>();
const excludedHeaders = new Set(["代表视频"]);
records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header) || excludedHeaders.has(header)) {
return;
}
seenHeaders.add(header);
orderedHeaders.push(header);
});
});
if (orderedHeaders.length === 0) {
return FALLBACK_BASE_COLUMNS;
}
return orderedHeaders.map((header) => ({
header,
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
import {
findNextPageControl,
isPageControlDisabled,
readMarketPageSignature
} from "./dom-sync";
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
interface ExportRangeControllerOptions {
document: Document;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[];
readCurrentPageRowCount(): number;
window: Window;
}
export function createExportRangeController(options: ExportRangeControllerOptions) {
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
const mergedRecords = new Map<string, MarketRecord>();
let currentPage = 0;
let expectedMinimumRowCount: number | undefined;
while (true) {
currentPage += 1;
options.onProgress?.({
currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined
});
const currentPageRecords = await preparePageRecords(expectedMinimumRowCount);
if (!currentPageRecords) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`);
}
currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
expectedMinimumRowCount = Math.max(
expectedMinimumRowCount ?? 0,
currentPageRecords.length
);
if (target.mode === "count" && currentPage >= target.pageCount) {
break;
}
const previousSignature = readMarketPageSignature(options.document);
const nextPageControl = findNextPageControl(options.document);
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
break;
}
nextPageControl.click();
const pageChanged = await waitForPageChange(previousSignature);
if (!pageChanged) {
throw new Error(`${currentPage + 1} 页导出失败,请稍后重试`);
}
}
return Array.from(mergedRecords.values());
}
};
async function preparePageRecords(
expectedMinimumRowCount: number | undefined
): Promise<MarketRecord[] | null> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const currentPageReady = await waitForCurrentPageReady();
if (!currentPageReady) {
return null;
}
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
if (
currentPageRecords.length > 0 &&
(
typeof expectedMinimumRowCount !== "number" ||
expectedMinimumRowCount <= 0 ||
isCurrentPageTerminal() ||
currentPageRecords.length >= expectedMinimumRowCount
)
) {
return currentPageRecords;
}
}
return null;
}
async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature);
for (let attempt = 0; attempt < 60; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 50);
});
await Promise.resolve();
const nextSignature = readMarketPageSignature(options.document);
const nextPageState = parsePageSignature(nextSignature);
if (hasLoadedNextPage(previousPageState, nextPageState)) {
return true;
}
}
return false;
}
async function waitForCurrentPageReady(): Promise<boolean> {
let stableAttemptCount = 0;
let lastReadyFingerprint = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 150);
});
await Promise.resolve();
const pageState = readCurrentPageState();
if (!pageState.authorIds || pageState.rowCount <= 0) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [
pageState.pageToken,
pageState.authorIds,
String(pageState.rowCount),
pageState.isTerminalPage ? "terminal" : "paged"
].join("::");
if (readyFingerprint === lastReadyFingerprint) {
stableAttemptCount += 1;
} else {
lastReadyFingerprint = readyFingerprint;
stableAttemptCount = 1;
}
if (stableAttemptCount >= 6) {
return true;
}
}
return false;
}
function readCurrentPageState(): {
authorIds: string;
isTerminalPage: boolean;
pageToken: string;
rowCount: number;
} {
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
const nextPageControl = findNextPageControl(options.document);
return {
authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRowCount()
};
}
function isCurrentPageTerminal(): boolean {
return isPageControlDisabled(findNextPageControl(options.document));
}
}
function parsePageSignature(signature: string): {
authorIds: string;
pageToken: string;
} {
const separatorIndex = signature.indexOf("::");
if (separatorIndex < 0) {
return {
authorIds: "",
pageToken: signature.trim()
};
}
return {
authorIds: signature.slice(separatorIndex + 2).trim(),
pageToken: signature.slice(0, separatorIndex).trim()
};
}
function hasLoadedNextPage(
previousPageState: {
authorIds: string;
pageToken: string;
},
nextPageState: {
authorIds: string;
pageToken: string;
}
): boolean {
if (!nextPageState.authorIds) {
return false;
}
if (nextPageState.pageToken || previousPageState.pageToken) {
return nextPageState.pageToken !== previousPageState.pageToken;
}
return nextPageState.authorIds !== previousPageState.authorIds;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates)
};
}
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: mergeStatus(existingRecord.status, incomingRecord.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 mergeStatus(
current: MarketRecordStatus,
incoming: MarketRecordStatus
): MarketRecordStatus {
const priority: Record<MarketRecordStatus, number> = {
failed: 1,
idle: 0,
loading: 2,
missing: -1,
success: 3
};
return priority[incoming] >= priority[current] ? incoming : current;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
@@ -0,0 +1,178 @@
import {
compareRateValues,
parseRateLowerBound
} from "../../shared/rate-normalizer";
import type {
AfterSearchRates,
BackendMetrics,
MarketSortField,
MarketFilterState,
MarketRecord,
MarketSortState
} from "./types";
interface ApplyFilterAndSortOptions {
filters?: MarketFilterState;
sort?: MarketSortState;
}
export function applyFilterAndSort(
records: MarketRecord[],
options: ApplyFilterAndSortOptions = {}
): MarketRecord[] {
const filteredRecords = records.filter((record) =>
matchesFilters(record, options.filters)
);
if (!options.sort) {
return filteredRecords;
}
return [...filteredRecords].sort((leftRecord, rightRecord) =>
compareRecords(leftRecord, rightRecord, options.sort as MarketSortState)
);
}
function matchesFilters(
record: MarketRecord,
filters: MarketFilterState | undefined
): boolean {
if (!filters) {
return true;
}
return (
meetsThreshold(
record.rates?.singleVideoAfterSearchRate,
filters.singleVideoAfterSearchRateMin
) &&
meetsThreshold(
record.rates?.personalVideoAfterSearchRate,
filters.personalVideoAfterSearchRateMin
)
);
}
function meetsThreshold(
rateValue: string | undefined,
minValue: number | undefined
): boolean {
if (minValue == null) {
return true;
}
const lowerBound = parseRateLowerBound(rateValue ?? null);
return lowerBound != null && lowerBound >= minValue;
}
function compareRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
if (isRateSortField(sort.field)) {
return compareRateSortRecords(leftRecord, rightRecord, sort);
}
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
}
function compareRateSortRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<AfterSearchRates>;
const leftValue = leftRecord.rates?.[field];
const rightValue = rightRecord.rates?.[field];
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
if (leftLowerBound == null && rightLowerBound == null) {
return compareRecordIdentity(leftRecord, rightRecord);
}
if (leftLowerBound == null) {
return 1;
}
if (rightLowerBound == null) {
return -1;
}
if (leftLowerBound !== rightLowerBound) {
return sort.direction === "asc"
? leftLowerBound - rightLowerBound
: rightLowerBound - leftLowerBound;
}
const tieBreak = compareRateValues(leftValue, rightValue);
if (tieBreak !== 0) {
return sort.direction === "asc" ? tieBreak : -tieBreak;
}
return compareRecordIdentity(leftRecord, rightRecord);
}
function compareBackendMetricRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<BackendMetrics>;
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
if (leftValue == null && rightValue == null) {
return compareRecordIdentity(leftRecord, rightRecord);
}
if (leftValue == null) {
return 1;
}
if (rightValue == null) {
return -1;
}
if (leftValue !== rightValue) {
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
return compareRecordIdentity(leftRecord, rightRecord);
}
function parseBackendMetricValue(value: string | null | undefined): number | null {
if (!value) {
return null;
}
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
if (!normalizedValue) {
return null;
}
const numericValue = Number(normalizedValue);
return Number.isFinite(numericValue) ? numericValue : null;
}
function isRateSortField(
field: MarketSortField
): field is keyof Required<AfterSearchRates> {
return (
field === "singleVideoAfterSearchRate" ||
field === "personalVideoAfterSearchRate"
);
}
function compareRecordIdentity(
leftRecord: MarketRecord,
rightRecord: MarketRecord
): number {
const authorIdCompare = leftRecord.authorId.localeCompare(rightRecord.authorId);
if (authorIdCompare !== 0) {
return authorIdCompare;
}
return leftRecord.authorName.localeCompare(rightRecord.authorName);
}
@@ -0,0 +1,94 @@
import type {
MarketApiFailureReason,
MarketApiResult,
MarketRowSnapshot
} from "./types";
import type { AfterSearchRates } from "./types";
interface ResultStoreLike {
setAuthorFailed(authorId: string, reason: MarketApiFailureReason): void;
setAuthorLoading(authorId: string): void;
setAuthorSuccess(authorId: string, rates: AfterSearchRates): void;
upsertMarketRow(row: MarketRowSnapshot): void;
}
interface FullScanControllerOptions {
goToNextPage(): Promise<boolean>;
hasNextPage(): boolean;
loadAuthorMetrics(authorId: string): Promise<MarketApiResult>;
readCurrentPageRows(): MarketRowSnapshot[];
resultStore: ResultStoreLike;
}
export function createFullScanController(options: FullScanControllerOptions) {
let completedScan = false;
let scanPromise: Promise<void> | null = null;
return {
ensureScanForExport() {
return ensureScan();
},
ensureScanForFilter() {
return ensureScan();
},
ensureScanForSort() {
return ensureScan();
}
};
function ensureScan(): Promise<void> {
if (completedScan) {
return Promise.resolve();
}
if (scanPromise) {
return scanPromise;
}
scanPromise = runScan().finally(() => {
scanPromise = null;
});
return scanPromise;
}
async function runScan(): Promise<void> {
do {
await scanCurrentPage();
if (!options.hasNextPage()) {
completedScan = true;
return;
}
} while (await options.goToNextPage());
completedScan = true;
}
async function scanCurrentPage(): Promise<void> {
const rows = options.readCurrentPageRows();
for (const row of rows) {
options.resultStore.upsertMarketRow(row);
if (row.hasDirectRatesSource) {
const directRates = row.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
options.resultStore.setAuthorSuccess(row.authorId, directRates);
if (hasAllRates) {
continue;
}
}
options.resultStore.setAuthorLoading(row.authorId);
const metricsResult = await options.loadAuthorMetrics(row.authorId);
if (metricsResult.success) {
options.resultStore.setAuthorSuccess(row.authorId, metricsResult.rates);
continue;
}
options.resultStore.setAuthorFailed(row.authorId, metricsResult.reason);
}
}
}
-98
View File
@@ -1,98 +0,0 @@
import { getStarIdFromUrl } from "../../shared/get-star-id";
type ExtractAuthorIdSuccessResult = {
authorId: string;
source: "attribute" | "detail-link";
success: true;
};
type ExtractAuthorIdFailureResult = {
authorId: null;
reason: "missing-author-id";
success: false;
};
export type ExtractAuthorIdResult =
| ExtractAuthorIdSuccessResult
| ExtractAuthorIdFailureResult;
const ATTRIBUTE_NAMES = [
"data-author-id",
"data-authorid",
"data-author_id",
"author_id"
] as const;
export function extractAuthorIdFromRow(row: Element): ExtractAuthorIdResult {
const detailLinkAuthorId = extractAuthorIdFromDetailLink(row);
if (detailLinkAuthorId) {
return {
authorId: detailLinkAuthorId,
source: "detail-link",
success: true
};
}
const attributeAuthorId = extractAuthorIdFromAttributes(row);
if (attributeAuthorId) {
return {
authorId: attributeAuthorId,
source: "attribute",
success: true
};
}
return {
authorId: null,
reason: "missing-author-id",
success: false
};
}
function extractAuthorIdFromDetailLink(row: Element): string | null {
for (const link of row.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
if (!href) {
continue;
}
const authorId = getStarIdFromUrl(toAbsoluteUrl(href));
if (authorId) {
return authorId;
}
}
return null;
}
function extractAuthorIdFromAttributes(row: Element): string | null {
for (const attributeName of ATTRIBUTE_NAMES) {
const directMatch = readAttributeValue(row, attributeName);
if (directMatch) {
return directMatch;
}
const descendant = row.querySelector(`[${attributeName}]`);
const descendantMatch = descendant
? readAttributeValue(descendant, attributeName)
: null;
if (descendantMatch) {
return descendantMatch;
}
}
return null;
}
function readAttributeValue(element: Element, attributeName: string): string | null {
const value = element.getAttribute(attributeName)?.trim() ?? "";
return /^\d+$/.test(value) ? value : null;
}
function toAbsoluteUrl(href: string): string {
try {
return new URL(href, "https://xingtu.cn").toString();
} catch {
return href;
}
}
+1631 -233
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
interface ListSignatureOptions {
authorIds: string[];
url: string;
}
export function createListSignature(options: ListSignatureOptions): string {
const parsedUrl = new URL(options.url);
return `${parsedUrl.pathname}${parsedUrl.search}::${options.authorIds.join(",")}`;
}
@@ -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;
}
+332
View File
@@ -0,0 +1,332 @@
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 MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type MarketRow = {
attribute_datas?: Record<string, unknown>;
nick_name?: string;
star_id?: string;
};
declare global {
interface Window {
[BRIDGE_MARKER]?: boolean;
}
}
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();
}, 1000);
}
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: 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() {
const marketList = readMarketList();
return marketList
.map((row) => {
const attributeDatas = isRecord(row.attribute_datas) ? row.attribute_datas : {};
const singleVideoAfterSearchRate = readNormalizedFractionRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId:
readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
authorName:
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
singleVideoAfterSearchRate
};
})
.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[] {
if (typeof document === "undefined") {
return [];
}
const marketRoot = document.querySelector(".base-author-list") as
| (HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
})
| null;
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef(value);
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
return candidate as MarketRow[];
}
if (!isRecord(candidate) || !Array.isArray(candidate.marketList)) {
continue;
}
if (looksLikeMarketList(candidate.marketList)) {
return candidate.marketList as MarketRow[];
}
}
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 {
const firstRow = value[0];
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
}
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 readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNormalizedFractionRate(value: unknown): string | undefined {
return typeof value === "string"
? normalizeFractionRateDisplay(value) ?? undefined
: undefined;
}
+642
View File
@@ -0,0 +1,642 @@
import type {
MarketExportScope,
MarketExportTarget
} from "./types";
export interface PluginToolbarHandlers {
onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void;
onConfigureAudienceProfileFields(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void;
}
export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
root: HTMLElement;
}
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
export function isPluginToolbarMounted(
root: HTMLElement,
document: Document
): boolean {
const actionRow = findNativeActionRow(document);
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
}
export function ensurePluginToolbar(
document: Document,
handlers: PluginToolbarHandlers
): PluginToolbarDom {
ensurePluginActionButtonTheme(document);
const existingRoot = document.querySelector(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
if (
existingRoot.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
)
) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
}
existingRoot.remove();
}
const root = document.createElement("section");
root.dataset.pluginToolbar = "root";
applyToolbarRootStyles(root);
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
exportRangeSelect.hidden = true;
appendOption(exportRangeSelect, "current", "当前页");
appendOption(exportRangeSelect, "first-5", "前5页");
appendOption(exportRangeSelect, "first-10", "前10页");
appendOption(exportRangeSelect, "all", "全部");
appendOption(exportRangeSelect, "custom", "自定义");
exportRangeSelect.value = "first-5";
const exportCustomPagesInput = document.createElement("input");
exportCustomPagesInput.type = "number";
exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true;
exportCustomPagesInput.placeholder = "页数";
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.hidden = true;
exportButton.tabIndex = -1;
const audienceProfileExportButton = document.createElement("button");
audienceProfileExportButton.type = "button";
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
audienceProfileExportButton.textContent = "导出选中达人数据";
audienceProfileExportButton.title =
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
const audienceProfileByIdExportButton = document.createElement("button");
audienceProfileByIdExportButton.type = "button";
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
audienceProfileByIdExportButton.textContent = "按星图ID导出";
audienceProfileByIdExportButton.title =
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
const audienceProfileFieldButton = document.createElement("button");
audienceProfileFieldButton.type = "button";
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
audienceProfileFieldButton.textContent = "选择字段";
audienceProfileFieldButton.title =
"勾选本次CSV需要导出的字段,设置会自动保存";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText);
root.append(
exportRangeSelect,
exportCustomPagesInput,
exportButton,
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportStatusText
);
document.body.appendChild(root);
applyNativeControlStyles(document, {
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect
});
ensureToolbarMounted(root, document);
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
audienceProfileExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfile();
});
audienceProfileByIdExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfileByIds();
});
audienceProfileFieldButton.addEventListener("click", () => {
void handlers.onConfigureAudienceProfileFields();
});
batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton,
audienceProfileExportButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
root
});
});
const toolbarDom = {
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
root
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
function appendOption(
select: HTMLSelectElement,
value: string,
label: string
): void {
const option = select.ownerDocument.createElement("option");
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
audienceProfileByIdExportButton: root.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement,
audienceProfileExportButton: root.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement,
audienceProfileFieldButton: root.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement,
batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement,
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
exportCustomPagesInput: root.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement,
exportRangeSelect: root.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement,
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
root
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
export function readToolbarExportTarget(
toolbar: PluginToolbarDom
): { error?: string; target?: MarketExportTarget } {
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
if (scope === "all") {
return {
target: {
mode: "all"
}
};
}
if (scope === "current") {
return {
target: {
mode: "count",
pageCount: 1
}
};
}
if (scope === "first-5") {
return {
target: {
mode: "count",
pageCount: 5
}
};
}
if (scope === "first-10") {
return {
target: {
mode: "count",
pageCount: 10
}
};
}
const pageCount = Number(toolbar.exportCustomPagesInput.value);
if (!Number.isInteger(pageCount) || pageCount < 1) {
return {
error: "请输入有效页数"
};
}
return {
target: {
mode: "count",
pageCount
}
};
}
export function setToolbarBusyState(
toolbar: PluginToolbarDom,
isBusy: boolean
): void {
[
toolbar.batchSubmitButton,
toolbar.audienceProfileFieldButton,
toolbar.audienceProfileByIdExportButton,
toolbar.audienceProfileExportButton,
toolbar.exportButton,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
].forEach((element) => {
element.disabled = isBusy;
});
}
export function setToolbarExportStatus(
toolbar: PluginToolbarDom,
text: string
): void {
toolbar.exportStatusText.textContent = text;
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportRangeSelect.hidden = true;
toolbar.exportCustomPagesInput.hidden = true;
}
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
const actionRow = findNativeActionRow(document);
if (!actionRow) {
root.hidden = true;
return;
}
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
const insertionAnchor = customizeButton
? findDirectChildAnchor(actionRow, customizeButton)
: null;
if (insertionAnchor) {
actionRow.insertBefore(root, insertionAnchor);
} else if (root.parentElement !== actionRow) {
actionRow.prepend(root);
}
root.hidden = false;
}
function findNativeActionRow(document: Document): HTMLElement | null {
const customizeButton = findNativeActionButton(document, "自定义指标");
const exportButton = findNativeActionButton(document, "导出");
const header = findHeaderContainer(customizeButton, exportButton);
const sharedActionRow =
customizeButton && exportButton
? findSmallestSharedActionRow(customizeButton, exportButton, header)
: null;
if (sharedActionRow) {
return sharedActionRow;
}
const scope = header ?? document;
const candidates = Array.from(
scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header")
).filter((element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
const rankedCandidates = candidates
.filter((candidate) =>
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
)
.sort((left, right) => {
const depthDelta = getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header);
if (depthDelta !== 0) {
return depthDelta;
}
return normalizeText(left.textContent).length - normalizeText(right.textContent).length;
});
return rankedCandidates[0] ?? null;
}
function findHeaderContainer(
customizeButton: HTMLElement | null,
exportButton: HTMLElement | null
): HTMLElement | null {
return (
(customizeButton?.closest(".search-content--header") as HTMLElement | null) ??
(exportButton?.closest(".search-content--header") as HTMLElement | null)
);
}
function findSmallestSharedActionRow(
customizeButton: HTMLElement,
exportButton: HTMLElement,
boundary: HTMLElement | null
): HTMLElement | null {
const exportAncestors = new Set(collectAncestorChain(exportButton, boundary));
for (const candidate of collectAncestorChain(customizeButton, boundary)) {
if (
exportAncestors.has(candidate) &&
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
) {
return candidate;
}
}
return null;
}
function collectAncestorChain(
element: HTMLElement,
boundary: HTMLElement | null
): HTMLElement[] {
const ancestors: HTMLElement[] = [];
let current: HTMLElement | null = element.parentElement;
while (current) {
ancestors.push(current);
if (current === boundary) {
break;
}
current = current.parentElement;
}
return ancestors;
}
function isNativeActionRowCandidate(
candidate: HTMLElement,
customizeButton: HTMLElement | null,
exportButton: HTMLElement | null
): boolean {
if (customizeButton && !candidate.contains(customizeButton)) {
return false;
}
if (exportButton && !candidate.contains(exportButton)) {
return false;
}
const directChildLabels = Array.from(candidate.children)
.flatMap((child) => {
const buttons: Element[] = [];
if (child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement) {
buttons.push(child);
}
buttons.push(...Array.from(child.querySelectorAll("button")));
return buttons;
})
.map((button) => normalizeText(button.textContent));
return (
directChildLabels.includes("导出") &&
(directChildLabels.includes("自定义指标") || Boolean(customizeButton))
);
}
function getDepthWithinAncestor(
element: HTMLElement,
boundary: HTMLElement | null
): number {
let depth = 0;
let current: HTMLElement | null = element.parentElement;
while (current && current !== boundary) {
depth += 1;
current = current.parentElement;
}
return depth;
}
function findNativeActionButton(
root: ParentNode,
text: string
): HTMLElement | null {
const document = root instanceof Document ? root : root.ownerDocument;
if (!document) {
return null;
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return (
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
);
}
function applyToolbarRootStyles(root: HTMLElement): void {
root.style.display = "inline-flex";
root.style.alignItems = "center";
root.style.columnGap = "8px";
root.style.flexWrap = "wrap";
}
function applyNativeControlStyles(
document: Document,
controls: {
audienceProfileExportButton: HTMLButtonElement;
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
}
): void {
const primaryButton =
findButtonContainingText(document, "发布任务") ??
findButtonContainingText(document, "+发布任务");
const nativeButton =
primaryButton ??
findNativeActionButton(document, "自定义指标") ??
findNativeActionButton(document, "导出");
if (nativeButton) {
controls.audienceProfileExportButton.className = nativeButton.className;
controls.audienceProfileByIdExportButton.className = nativeButton.className;
controls.audienceProfileFieldButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className;
}
[
controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton
].forEach((button) => {
applyPrimaryButtonStyles(button);
button.style.whiteSpace = "nowrap";
});
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
element.style.height = "32px";
element.style.border = "1px solid #d0d7de";
element.style.borderRadius = "6px";
element.style.padding = "0 10px";
element.style.background = "#fff";
element.style.color = "#1f2329";
element.style.boxSizing = "border-box";
});
controls.exportRangeSelect.style.minWidth = "104px";
controls.exportCustomPagesInput.style.width = "72px";
}
function applyPrimaryButtonStyles(
button: HTMLButtonElement
): void {
button.style.backgroundColor = "#7f1d2d";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.color = "#ffffff";
button.style.height = "32px";
button.style.padding = "0 15px";
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 {
statusText.style.color = "#64748b";
statusText.style.fontSize = "12px";
statusText.style.lineHeight = "20px";
statusText.style.marginLeft = "4px";
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 {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function findButtonContainingText(
root: ParentNode,
text: string
): HTMLElement | null {
const document = root instanceof Document ? root : root.ownerDocument;
if (!document) {
return null;
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
}
function findDirectChildAnchor(
ancestor: HTMLElement,
descendant: HTMLElement
): HTMLElement | null {
let current: HTMLElement | null = descendant;
let previous: HTMLElement | null = null;
while (current && current !== ancestor) {
previous = current;
current = current.parentElement;
}
return current === ancestor ? previous : null;
}
+152
View File
@@ -0,0 +1,152 @@
import type {
BackendMetrics,
MarketApiFailureReason,
MarketRecord,
MarketRowSnapshot
} from "./types";
import type { AfterSearchRates } from "./types";
export function createMarketResultStore() {
const records = new Map<string, MarketRecord>();
return {
getRecord(authorId: string) {
return records.get(authorId) ?? null;
},
listRecords() {
return Array.from(records.values());
},
setAuthorFailed(authorId: string, reason: MarketApiFailureReason) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "failed";
existingRecord.failureReason = reason;
},
setAuthorLoading(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "loading";
delete existingRecord.failureReason;
},
setBackendMetricsFailed(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "failed";
},
setBackendMetricsLoading(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "loading";
},
setBackendMetricsMissing(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "missing";
},
setBackendMetricsSuccess(authorId: string, backendMetrics: BackendMetrics) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "success";
existingRecord.backendMetrics = {
...existingRecord.backendMetrics,
...backendMetrics
};
},
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "success";
existingRecord.rates = {
...existingRecord.rates,
...rates
};
delete existingRecord.failureReason;
},
upsertMarketRow(row: MarketRowSnapshot) {
const existingRecord = records.get(row.authorId);
if (existingRecord) {
existingRecord.authorName =
mergeStringValue(existingRecord.authorName, row.authorName) ??
existingRecord.authorName;
existingRecord.coreUserId = mergeStringValue(
existingRecord.coreUserId,
row.coreUserId
);
existingRecord.location = mergeStringValue(
existingRecord.location,
row.location
);
existingRecord.price21To60s = mergeStringValue(
existingRecord.price21To60s,
row.price21To60s
);
existingRecord.exportFields = mergeFieldMap(
existingRecord.exportFields,
row.exportFields
);
existingRecord.backendMetrics = mergeFieldMap(
existingRecord.backendMetrics,
row.backendMetrics
);
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
return existingRecord;
}
const nextRecord: MarketRecord = {
...row,
backendMetricsStatus: "idle",
status: "idle"
};
records.set(row.authorId, nextRecord);
return nextRecord;
}
};
function ensureRecord(authorId: string): MarketRecord {
const existingRecord = records.get(authorId);
if (existingRecord) {
return existingRecord;
}
const nextRecord: MarketRecord = {
authorId,
authorName: authorId,
backendMetricsStatus: "idle",
status: "idle"
};
records.set(authorId, nextRecord);
return nextRecord;
}
}
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(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (!hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
-52
View File
@@ -1,52 +0,0 @@
import type { MarketRowDom } from "./dom-sync";
import type { MarketRowState } from "./row-state";
interface RenderMarketRowStateOptions {
onRetry?: () => void;
}
export function renderMarketRowState(
rowDom: MarketRowDom,
state: MarketRowState,
options: RenderMarketRowStateOptions = {}
) {
applySharedMetadata(rowDom, state);
if (state.state === "loading") {
setCellState(rowDom, "加载中...");
return;
}
if (state.state === "success") {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = state.singleVideoAfterSearchRate;
rowDom.personalCell.textContent = state.personalVideoAfterSearchRate;
return;
}
setCellState(rowDom, "加载失败");
setRetryHandler(rowDom, state.retryable ? options.onRetry : undefined);
}
function applySharedMetadata(rowDom: MarketRowDom, state: MarketRowState) {
const authorId = state.authorId ?? "";
const listSeq = String(state.listSeq);
rowDom.row.dataset.scesAuthorId = authorId;
rowDom.row.dataset.scesListSeq = listSeq;
rowDom.singleCell.dataset.scesAuthorId = authorId;
rowDom.singleCell.dataset.scesListSeq = listSeq;
rowDom.personalCell.dataset.scesAuthorId = authorId;
rowDom.personalCell.dataset.scesListSeq = listSeq;
}
function setCellState(rowDom: MarketRowDom, text: string) {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = text;
rowDom.personalCell.textContent = text;
}
function setRetryHandler(rowDom: MarketRowDom, onRetry?: () => void) {
rowDom.singleCell.onclick = onRetry ?? null;
rowDom.personalCell.onclick = onRetry ?? null;
}

Some files were not shown because too many files have changed in this diff Show More