Compare commits

..

No commits in common. "main" and "progress-2026-04-22-logto-backend-metrics" have entirely different histories.

57 changed files with 817 additions and 9718 deletions

14
.gitignore vendored
View File

@ -1,18 +1,4 @@
.worktrees/ .worktrees/
.old-reference/ .old-reference/
.local/
dist/ dist/
dist-release/
release/
node_modules/ node_modules/
# 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

218
README.md
View File

@ -1,191 +1,85 @@
# 星图增强插件 # Star Chart Search Enhancer
这是一个供公司内部使用的 Chrome MV3 插件,用于增强巨量星图达人市场页面的使用体验。 Chrome MV3 extension for the Xingtu creator market page.
主要功能: ## Development
- 在星图达人列表页补充插件侧数据列
- 支持勾选部分达人后导出 CSV
- 支持将达人数据提交为批次
- 集成 Logto 登录
- 支持内部压缩包分发后通过 `Load unpacked` 安装
当前固定扩展 ID
- `pkjopdibdnomhogjheclhnknmejccffg`
---
## 一、项目目录
- `src/`
- 插件源码
- `dist/`
- 开发构建产物
- `dist-release/`
- 内部分发构建产物
- `release/`
- 打包后的内部交付压缩包
- `docs/`
- 项目说明文档
- `tests/`
- 自动化测试
- `scripts/`
- 构建和打包脚本
---
## 二、开发环境
安装依赖:
```bash ```bash
npm install npm install
```
运行测试:
```bash
npm test npm test
```
开发构建:
```bash
npm run build npm run build
``` ```
说明: ## Load The Extension
- `npm run build` 会生成开发版到 `dist/` 1. Run `npm run build`
- 开发版包含本地调试需要的宽权限 2. Open `chrome://extensions`
3. Enable developer mode
4. Choose `Load unpacked`
5. Select the `dist/` directory
--- ## Current Scope
## 三、内部交付构建 - Adds two after-search-rate columns to the Xingtu market list
- Adds a popup-based Logto auth entry
- Hydrates the current page immediately
- Provides plugin-owned filter, sort, and CSV export controls
- Gates the market tools until auth is available
- Triggers full-scan flow only when filter, sort, or export is used
生成内部使用构建: ## Auth Configuration
```bash The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`.
npm run build:release Replace these before real sign-in testing:
```
生成内部压缩包:
```bash
npm run package:internal
```
生成结果:
- 构建目录:`dist-release/`
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
说明:
- 这个压缩包不是给 Chrome 商店上传的
- 它是发给公司内部同事使用的交付包
- 同事收到后需要解压,再到 `chrome://extensions``Load unpacked`
---
## 四、插件安装方式
本项目当前采用公司内部手工安装方式:
1. 解压内部压缩包
2. 打开 `chrome://extensions`
3. 打开右上角 `开发者模式`
4. 点击 `加载已解压的扩展程序`
5. 选择解压后的插件文件夹
安装后请确认扩展 ID 是:
- `pkjopdibdnomhogjheclhnknmejccffg`
---
## 五、认证与配置
插件使用 Logto 登录。
认证配置位于:
- `src/shared/auth-config.ts`
当前主要配置包括:
- `logtoEndpoint` - `logtoEndpoint`
- `appId` - `appId`
- `apiResource` - `apiResource`
- `scopes` - Any extra scopes beyond `openid`, `profile`, and `offline_access`
说明: The popup dev panel is controlled by `enableDevAuthPanel`.
- popup 中的开发调试面板默认关闭 ## Popup Behavior
- 如果需要本地调试受保护接口,可以手动把 `enableDevAuthPanel` 改为 `true`
--- 1. Load the unpacked extension from `dist/`
2. Click the extension icon
3. Confirm the popup shows `登录 Logto` when unauthenticated
4. After real Logto config is added, use the popup to sign in and sign out
## 六、批次提交说明 ## Protected API Mock Test
提交批次时,前端当前会提交以下核心字段: 1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
2. Run `npm run mock:protected-api`
3. Run `npm run build`
4. Reload the unpacked extension from `dist/`
5. Open the popup and log in
6. Click `测试受保护接口`
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
- `logtoUserId` ## Batch Submit Mock Test
- `creatorName`
- `resource`
- `batchName`
- `createdAt`
- `authors`
说明: 1. Run `npm run mock:protected-api`
2. Run `npm run build`
3. Reload the unpacked extension from `dist/`
4. Open `https://xingtu.cn/ad/creator/market`
5. Choose an export range in the plugin toolbar
6. Click `提交批次`
7. Enter a batch name in the browser prompt
8. Confirm the toolbar shows `批次提交成功`
9. Confirm the mock batch response accepts the payload and reports the submitted `batchId`
- `batchId` 不再由前端生成 ## Market Auth Gate
- 现在由后端生成 7 位数字批次 ID
--- When the market page is opened without a valid auth state, the content script renders
`请先登录插件` and does not boot the filter, sort, or export toolbar.
## 七、重要文档 ## Manual Verification
给内部同事的安装与使用说明: 1. Load the unpacked extension from `dist/`
2. Open `https://xingtu.cn/ad/creator/market`
- `docs/aigc-user-guide.md` 3. Confirm the page shows the auth gate until login is available
4. After authentication is wired, confirm the two new columns appear
内部压缩包分发说明: 5. Confirm current-page rows move through loading and then render values or failure states
6. Apply a threshold filter and confirm the list hides unmatched rows
- `docs/internal-extension-distribution.md` 7. Apply a sort and confirm row order changes
8. Export CSV and confirm the file includes plugin status and after-search-rate fields
---
## 八、常用命令
```bash
npm install
npm test
npm run build
npm run build:release
npm run package:internal
```
---
## 九、维护注意事项
- 扩展 ID 已通过 `manifest.key` 固定
- 不要泄露本地私钥文件 `.local/extension-key.pem`
- 如果后端地址发生变化,需要同步更新:
- `scripts/manifest.mjs`
- 对应后端配置文件
- 相关文档
---
## 十、当前状态
当前项目已经支持:
- 新固定扩展 ID
- 内部压缩包分发
- 自定义批次名称弹窗
- 后台静默导出
- 批次提交不再由前端生成 `batchId`

View File

@ -1,502 +0,0 @@
# 星图增强插件使用说明
适用对象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`:把当前选择的数据导出为表格文件
- `提交批次`:把当前选择的数据提交为批次
---
## 八、如何选择达人
页面上每个达人前面都有一个勾选框。
你可以:
- 单独勾选某几个达人
- 勾选表头复选框,快速全选当前页
规则说明:
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
- 如果你没有勾选任何达人,则默认按当前导出范围处理全部达人
---
## 九、导出 CSV 的方法
### 1. 选择导出范围
在插件工具栏中,你会看到导出范围下拉框,可选:
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
如果选择 `自定义`,需要再输入页数。
### 2. 如果只想导出部分达人
先勾选你想要的达人,再点击导出。
这样导出的 CSV 只会包含你勾选的人。
### 3. 点击导出
点击:
- `导出CSV`
### 4. 等待导出完成
页面上会显示状态,例如:
- `导出中...`
导出完成后,浏览器会自动下载 CSV 文件。
### 5. 去哪里找文件
通常会在浏览器默认下载目录里找到。
如果你不确定下载到了哪里:
1. 打开 Chrome 下载列表
2. 找到最新下载的 CSV 文件
---
## 十、提交批次的方法
### 1. 选择范围
和导出一样,先选择导出范围:
- 当前页
- 前5页
- 前10页
- 全部
- 自定义
### 2. 如果只想提交部分达人
先勾选想提交的达人。
### 3. 点击提交
点击:
- `提交批次`
### 4. 输入批次名称
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
你会看到:
- 标题
- 输入框
- `取消`
- `确认提交`
建议批次名称写得清楚一些,例如:
- `618达人筛选第一批`
- `食品饮料-KOL测试批次`
- `5月女装达人候选`
### 5. 确认提交
输入后点击:
- `确认提交`
也可以直接按回车提交。
### 6. 提交成功的提示
如果成功,页面状态会显示:
- `批次提交成功`
如果失败,会看到错误提示。
---
## 十一、批次名称填写建议
建议使用容易看懂的命名方式:
- 时间 + 主题
- 项目 + 人群
- 场景 + 顺序编号
推荐示例:
- `2026-05-电商零食达人初筛`
- `AIGC视频合作达人第一批`
- `母婴品类候选达人-第1批`
不推荐示例:
- `测试`
- `aaa`
- `批次1`
原因:
- 后续回看时不容易分辨
- 团队协作时不方便沟通
---
## 十二、如何更新插件
当你收到新的插件压缩包时,不需要重新从零安装。
按照下面做:
### 方法一:替换文件夹后重新加载
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. 需要表格时点 `导出CSV`
8. 需要进入后续流程时点 `提交批次`
---
## 十五、遇到问题时请这样反馈
如果需要找维护同事帮忙,请尽量一次性提供以下信息:
- 你在做什么操作
- 出问题的时间
- 页面截图
- 浏览器里看到的提示文字
- 扩展 ID 是否为 `pkjopdibdnomhogjheclhnknmejccffg`
这样能更快定位问题。
---
## 十六、给安装同事的一句话版本
如果你要把最短说明发给同事,可以直接复制下面这段:
```text
1. 解压收到的插件压缩包
2. 打开 chrome://extensions
3. 打开右上角“开发者模式”
4. 点击“加载已解压的扩展程序”
5. 选择解压后的文件夹
6. 确认插件名称是 Star Chart Search Enhancer
7. 确认扩展 ID 是 pkjopdibdnomhogjheclhnknmejccffg
8. 点击右上角插件图标完成登录
9. 打开巨量星图达人市场页面开始使用
```

View File

@ -1,31 +0,0 @@
# 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. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
## 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.

View File

@ -1,79 +0,0 @@
# 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"
```

View File

@ -1,61 +0,0 @@
# 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.

View File

@ -1,161 +0,0 @@
# 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"
```

View File

@ -1,181 +0,0 @@
# 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

View File

@ -1,63 +0,0 @@
# 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

View File

@ -1,35 +0,0 @@
# 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.

View File

@ -1,94 +0,0 @@
# 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 结构

View File

@ -1,183 +0,0 @@
# 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

View File

@ -5,10 +5,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
"mock:protected-api": "node scripts/mock-protected-api.mjs", "mock:protected-api": "node scripts/mock-protected-api.mjs",
"package:internal": "npm run build:release && node scripts/package-release.mjs",
"package:release": "npm run build:release && node scripts/package-release.mjs",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests" "test:watch": "vitest --passWithNoTests"
}, },

View File

@ -1,17 +1,12 @@
import { cp, mkdir, rm, writeFile } from "node:fs/promises"; import { cp, mkdir, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { build } from "tsup"; import { build } from "tsup";
import { createManifest } from "./manifest.mjs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, ".."); const projectRoot = path.resolve(__dirname, "..");
const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development"; const distDir = path.join(projectRoot, "dist");
const distDir = path.join(
projectRoot,
buildTarget === "release" ? "dist-release" : "dist"
);
await rm(distDir, { recursive: true, force: true }); await rm(distDir, { recursive: true, force: true });
await mkdir(path.join(distDir, "content"), { recursive: true }); await mkdir(path.join(distDir, "content"), { recursive: true });
@ -73,16 +68,11 @@ await build({
} }
}); });
await writeFile( await cp(
path.join(distDir, "manifest.json"), path.join(projectRoot, "src/manifest.json"),
`${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n` path.join(distDir, "manifest.json")
); );
await cp( await cp(
path.join(projectRoot, "src/popup/index.html"), path.join(projectRoot, "src/popup/index.html"),
path.join(distDir, "popup/index.html") path.join(distDir, "popup/index.html")
); );
await cp(
path.join(projectRoot, "src/assets"),
path.join(distDir, "assets"),
{ recursive: true }
);

View File

@ -1,76 +0,0 @@
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"],
version: "0.2.0421.2",
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/*"
]
};
export function createManifest(options = {}) {
const target = options.target ?? "development";
const hostPermissions = hostPermissionsByTarget[target];
if (!hostPermissions) {
throw new Error(`Unsupported manifest target: ${target}`);
}
return {
...sharedManifest,
host_permissions: hostPermissions
};
}

View File

@ -1,24 +0,0 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourceDir = path.join(projectRoot, "dist-release");
const releaseDir = path.join(projectRoot, "release");
const archivePath = path.join(
releaseDir,
"star-chart-search-enhancer-internal.zip"
);
await mkdir(releaseDir, { recursive: true });
await rm(archivePath, { force: true });
await execFileAsync("zip", ["-X", "-r", archivePath, "."], {
cwd: sourceDir
});
console.log(`Internal archive created at ${archivePath}`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 822 B

View File

@ -6,7 +6,6 @@ import {
} from "../shared/auth-messages"; } from "../shared/auth-messages";
import { createBatchSubmitClient } from "../shared/batch-submit-client"; import { createBatchSubmitClient } from "../shared/batch-submit-client";
import { createBackendMetricsClient } from "../shared/backend-metrics-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 { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages"; import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
@ -82,7 +81,7 @@ export function registerBackgroundMessageHandler(
authClient: createLogtoAuthClient() authClient: createLogtoAuthClient()
}); });
submitBatch ??= createBatchSubmitClient({ submitBatch ??= createBatchSubmitClient({
baseUrl: DEFAULT_BATCH_SUBMIT_BASE_URL, baseUrl: "http://127.0.0.1:4319",
getAccessToken: () => authController!.getAccessToken(), getAccessToken: () => authController!.getAccessToken(),
sendMessage: () => sendMessage: () =>
Promise.reject(new Error("background batch submit does not use sendMessage")) Promise.reject(new Error("background batch submit does not use sendMessage"))

View File

@ -39,18 +39,15 @@ export async function bootContentScript(
return null; return null;
} }
installMarketPageBridge(currentDocument);
const authState = await readAuthState(sendAuthMessage); const authState = await readAuthState(sendAuthMessage);
if (!authState?.isAuthenticated) { if (!authState?.isAuthenticated) {
await waitForBodyReady(currentDocument, currentWindow);
renderMarketAuthGate(currentDocument, currentWindow); renderMarketAuthGate(currentDocument, currentWindow);
return { return {
ready: Promise.resolve() ready: Promise.resolve()
}; };
} }
await waitForBodyReady(currentDocument, currentWindow); installMarketPageBridge(currentDocument);
return controllerFactory({ return controllerFactory({
document: currentDocument, document: currentDocument,
@ -147,24 +144,6 @@ function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
}; };
} }
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): void { function downloadCsv(document: Document, window: Window, csv: string): void {
const blob = new Blob(["\uFEFF", csv], { const blob = new Blob(["\uFEFF", csv], {
type: "text/csv;charset=utf-8" type: "text/csv;charset=utf-8"

View File

@ -109,7 +109,7 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
data.personal_avg_search_after_view_rate data.personal_avg_search_after_view_rate
); );
if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) { if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
return { return {
success: false, success: false,
reason: "missing-rate" reason: "missing-rate"
@ -119,8 +119,8 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
return { return {
success: true, success: true,
rates: { rates: {
...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}), singleVideoAfterSearchRate,
...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {}) personalVideoAfterSearchRate
} }
}; };
} }

View File

@ -1,245 +0,0 @@
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";
}

View File

@ -6,6 +6,7 @@ export interface BatchPayload {
authorId: string; authorId: string;
authorName: string; authorName: string;
}>; }>;
batchId: string;
batchName: string; batchName: string;
createdAt: string; createdAt: string;
creatorName: string; creatorName: string;
@ -39,6 +40,7 @@ export function createBatchPayload(options: {
authorId: record.authorId, authorId: record.authorId,
authorName: record.authorName authorName: record.authorName
})), })),
batchId: `${batchName}-${options.createdAt}`,
batchName, batchName,
createdAt: options.createdAt, createdAt: options.createdAt,
creatorName: creatorName:

View File

@ -43,40 +43,9 @@ const RATE_COLUMNS: CsvColumn[] = [
} }
]; ];
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
{
header: "看后搜率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchRate ?? ""
},
{
header: "看后搜数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchCount ?? ""
},
{
header: "新增A3数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.a3IncreaseCount ?? ""
},
{
header: "新增A3率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.newA3Rate ?? ""
},
{
header: "CPA3",
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
},
{
header: "cp_search",
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
}
];
export function buildMarketCsv(records: MarketRecord[]): string { export function buildMarketCsv(records: MarketRecord[]): string {
const baseColumns = buildBaseColumns(records); const baseColumns = buildBaseColumns(records);
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS]; const csvColumns = [...baseColumns, ...RATE_COLUMNS];
const headerLine = csvColumns.map((column) => column.header).join(","); const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) => const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",") csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
@ -88,11 +57,10 @@ export function buildMarketCsv(records: MarketRecord[]): string {
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] { function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = []; const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>(); const seenHeaders = new Set<string>();
const excludedHeaders = new Set(["代表视频"]);
records.forEach((record) => { records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => { Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header) || excludedHeaders.has(header)) { if (seenHeaders.has(header)) {
return; return;
} }

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,6 @@ interface ExportRangeControllerOptions {
onProgress?: (state: { currentPage: number; totalPages?: number }) => void; onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>; prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[]; readCurrentPageRecords(): MarketRecord[];
readCurrentPageRowCount(): number;
window: Window; window: Window;
} }
@ -27,10 +26,13 @@ export function createExportRangeController(options: ExportRangeControllerOption
currentPage, currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined totalPages: target.mode === "count" ? target.pageCount : undefined
}); });
const currentPageRecords = await preparePageRecords(expectedMinimumRowCount); const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
if (!currentPageRecords) { if (!currentPageReady) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`); throw new Error(`${currentPage} 页加载超时,请稍后重试`);
} }
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
currentPageRecords.forEach((record) => { currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId); const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record)); mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
@ -61,33 +63,6 @@ export function createExportRangeController(options: ExportRangeControllerOption
} }
}; };
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> { async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature); const previousPageState = parsePageSignature(previousSignature);
@ -107,7 +82,9 @@ export function createExportRangeController(options: ExportRangeControllerOption
return false; return false;
} }
async function waitForCurrentPageReady(): Promise<boolean> { async function waitForCurrentPageReady(
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
let stableAttemptCount = 0; let stableAttemptCount = 0;
let lastReadyFingerprint = ""; let lastReadyFingerprint = "";
@ -124,6 +101,17 @@ export function createExportRangeController(options: ExportRangeControllerOption
continue; continue;
} }
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [ const readyFingerprint = [
pageState.pageToken, pageState.pageToken,
pageState.authorIds, pageState.authorIds,
@ -158,13 +146,9 @@ export function createExportRangeController(options: ExportRangeControllerOption
authorIds: pageSignature.authorIds, authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl), isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken, pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRowCount() rowCount: options.readCurrentPageRecords().length
}; };
} }
function isCurrentPageTerminal(): boolean {
return isPageControlDisabled(findNextPageControl(options.document));
}
} }
function parsePageSignature(signature: string): { function parsePageSignature(signature: string): {

View File

@ -3,9 +3,6 @@ import {
parseRateLowerBound parseRateLowerBound
} from "../../shared/rate-normalizer"; } from "../../shared/rate-normalizer";
import type { import type {
AfterSearchRates,
BackendMetrics,
MarketSortField,
MarketFilterState, MarketFilterState,
MarketRecord, MarketRecord,
MarketSortState MarketSortState
@ -70,26 +67,13 @@ function compareRecords(
rightRecord: MarketRecord, rightRecord: MarketRecord,
sort: MarketSortState sort: MarketSortState
): number { ): number {
if (isRateSortField(sort.field)) { const leftValue = leftRecord.rates?.[sort.field];
return compareRateSortRecords(leftRecord, rightRecord, sort); const rightValue = rightRecord.rates?.[sort.field];
}
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 leftLowerBound = parseRateLowerBound(leftValue ?? null);
const rightLowerBound = parseRateLowerBound(rightValue ?? null); const rightLowerBound = parseRateLowerBound(rightValue ?? null);
if (leftLowerBound == null && rightLowerBound == null) { if (leftLowerBound == null && rightLowerBound == null) {
return compareRecordIdentity(leftRecord, rightRecord); return 0;
} }
if (leftLowerBound == null) { if (leftLowerBound == null) {
@ -107,72 +91,5 @@ function compareRateSortRecords(
} }
const tieBreak = compareRateValues(leftValue, rightValue); const tieBreak = compareRateValues(leftValue, rightValue);
if (tieBreak !== 0) { return sort.direction === "asc" ? tieBreak : -tieBreak;
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);
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,173 +0,0 @@
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;
}

View File

@ -1,513 +0,0 @@
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")) ??
"",
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;
}

View File

@ -1,11 +1,6 @@
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer"; import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
import {
writeMarketListRequestSnapshot
} from "./market-list-request-snapshot";
import { parseMarketListResponse } from "./market-list-row";
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__"; const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
const MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows"; const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type MarketRow = { type MarketRow = {
@ -29,7 +24,6 @@ function installMarketPageBridge() {
} }
window[BRIDGE_MARKER] = true; window[BRIDGE_MARKER] = true;
installMarketRequestSnapshotBridge();
syncSerializedMarketRows(); syncSerializedMarketRows();
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
@ -45,16 +39,7 @@ function installMarketPageBridge() {
}, 1000); }, 1000);
} }
function installMarketRequestSnapshotBridge() {
installFetchSnapshotBridge();
installXmlHttpRequestSnapshotBridge();
}
function syncSerializedMarketRows() { function syncSerializedMarketRows() {
if (typeof document === "undefined") {
return;
}
const nextSerializedRows = JSON.stringify(readSerializedMarketRows()); const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if ( if (
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
@ -67,131 +52,6 @@ function syncSerializedMarketRows() {
} }
} }
function installFetchSnapshotBridge() {
if (typeof window.fetch !== "function") {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const requestSnapshot = readFetchSnapshot(input, init);
const response = await originalFetch(input, init);
if (requestSnapshot) {
const clonedResponse = response.clone();
void captureMarketSnapshotFromResponse(requestSnapshot, () =>
clonedResponse.json()
);
}
return response;
};
}
function installXmlHttpRequestSnapshotBridge() {
const OriginalXmlHttpRequest = window.XMLHttpRequest;
if (!OriginalXmlHttpRequest) {
return;
}
const originalOpen = OriginalXmlHttpRequest.prototype.open;
const originalSend = OriginalXmlHttpRequest.prototype.send;
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
OriginalXmlHttpRequest.prototype.open = function (
method: string,
url: string | URL,
...rest: unknown[]
) {
(
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
method: string;
url: string;
};
}
).__scesMarketSnapshot = {
headers: {},
method,
url: String(url)
};
return originalOpen.call(this, method, url, ...(rest as [boolean?, string?, string?]));
};
OriginalXmlHttpRequest.prototype.setRequestHeader = function (
name: string,
value: string
) {
(
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
};
}
).__scesMarketSnapshot?.headers &&
((this as XMLHttpRequest & {
__scesMarketSnapshot?: {
headers: Record<string, string>;
};
}).__scesMarketSnapshot!.headers[name] = value);
return originalSetRequestHeader.call(this, name, value);
};
OriginalXmlHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
const snapshotState = (
this as XMLHttpRequest & {
__scesMarketSnapshot?: {
body?: string;
headers: Record<string, string>;
method: string;
url: string;
};
}
).__scesMarketSnapshot;
if (snapshotState) {
snapshotState.body = typeof body === "string" ? body : undefined;
this.addEventListener("load", () => {
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
return;
}
void captureMarketSnapshotFromResponse(snapshotState, async () =>
JSON.parse(this.responseText)
);
});
}
return originalSend.call(this, body);
};
}
async function captureMarketSnapshotFromResponse(
snapshot: {
body?: string;
headers?: Record<string, string>;
method: string;
url: string;
},
readPayload: () => Promise<unknown>
) {
if (!isMarketSearchRequest(snapshot.url)) {
return;
}
try {
const payload = await readPayload();
if (!parseMarketListResponse(payload)) {
return;
}
writeMarketListRequestSnapshot(document, {
body: snapshot.body,
headers: snapshot.headers,
method: snapshot.method,
url: snapshot.url
});
} catch {}
}
function readSerializedMarketRows() { function readSerializedMarketRows() {
const marketList = readMarketList(); const marketList = readMarketList();
return marketList return marketList
@ -211,59 +71,7 @@ function readSerializedMarketRows() {
.filter((row) => Boolean(row.authorId || row.authorName)); .filter((row) => Boolean(row.authorId || row.authorName));
} }
function readFetchSnapshot(
input: RequestInfo | URL,
init?: RequestInit
): {
body?: string;
headers?: Record<string, string>;
method: string;
url: string;
} | null {
const request = input instanceof Request ? input : null;
const method = init?.method ?? request?.method ?? "GET";
const url = request?.url ?? String(input);
const body =
typeof init?.body === "string"
? init.body
: typeof request?.bodyUsed === "boolean" && request.bodyUsed
? undefined
: undefined;
const headers = serializeHeaders(init?.headers ?? request?.headers);
return {
body,
headers,
method,
url
};
}
function serializeHeaders(
headers: HeadersInit | undefined
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)])
);
}
function readMarketList(): MarketRow[] { function readMarketList(): MarketRow[] {
if (typeof document === "undefined") {
return [];
}
const marketRoot = document.querySelector(".base-author-list") as const marketRoot = document.querySelector(".base-author-list") as
| (HTMLElement & { | (HTMLElement & {
__vue__?: { __vue__?: {
@ -294,15 +102,6 @@ function readMarketList(): MarketRow[] {
return []; return [];
} }
function isMarketSearchRequest(url: string): boolean {
return (
url === MARKET_SEARCH_REQUEST_PATH ||
url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
url.endsWith(MARKET_SEARCH_REQUEST_PATH)
);
}
function looksLikeMarketList(value: unknown[]): boolean { function looksLikeMarketList(value: unknown[]): boolean {
const firstRow = value[0]; const firstRow = value[0];
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow); return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);

View File

@ -1,9 +1,8 @@
import type { import type { MarketExportScope, MarketExportTarget } from "./types";
MarketExportScope,
MarketExportTarget
} from "./types";
export interface PluginToolbarHandlers { export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void; onExport(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void; onSubmitBatch(): Promise<void> | void;
} }
@ -14,36 +13,69 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement; exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement; exportStatusText: HTMLElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement; root: HTMLElement;
} singleFilterInput: HTMLInputElement;
sortApplyButton: HTMLButtonElement;
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style"; sortDirectionSelect: HTMLSelectElement;
sortFieldSelect: HTMLSelectElement;
export function isPluginToolbarMounted(
root: HTMLElement,
document: Document
): boolean {
const actionRow = findNativeActionRow(document);
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
} }
export function ensurePluginToolbar( export function ensurePluginToolbar(
document: Document, document: Document,
handlers: PluginToolbarHandlers handlers: PluginToolbarHandlers
): PluginToolbarDom { ): PluginToolbarDom {
ensurePluginActionButtonTheme(document);
const existingRoot = document.querySelector( const existingRoot = document.querySelector(
"[data-plugin-toolbar='root']" "[data-plugin-toolbar='root']"
) as HTMLElement | null; ) as HTMLElement | null;
if (existingRoot) { if (existingRoot) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot); return readToolbarDom(existingRoot);
} }
const root = document.createElement("section"); const root = document.createElement("section");
root.dataset.pluginToolbar = "root"; root.dataset.pluginToolbar = "root";
applyToolbarRootStyles(root);
const singleFilterInput = document.createElement("input");
singleFilterInput.type = "number";
singleFilterInput.step = "0.01";
singleFilterInput.dataset.pluginFilterSingle = "input";
const personalFilterInput = document.createElement("input");
personalFilterInput.type = "number";
personalFilterInput.step = "0.01";
personalFilterInput.dataset.pluginFilterPersonal = "input";
const filterApplyButton = document.createElement("button");
filterApplyButton.type = "button";
filterApplyButton.dataset.pluginFilterApply = "button";
filterApplyButton.textContent = "应用筛选";
const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select";
appendOption(sortDirectionSelect, "desc", "降序");
appendOption(sortDirectionSelect, "asc", "升序");
const sortApplyButton = document.createElement("button");
sortApplyButton.type = "button";
sortApplyButton.dataset.pluginSortApply = "button";
sortApplyButton.textContent = "应用排序";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
const exportRangeSelect = document.createElement("select"); const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select"; exportRangeSelect.dataset.pluginExportRange = "select";
@ -59,40 +91,32 @@ export function ensurePluginToolbar(
exportCustomPagesInput.min = "1"; exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1"; exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true; exportCustomPagesInput.hidden = true;
exportCustomPagesInput.placeholder = "页数";
exportCustomPagesInput.dataset.pluginExportCustomPages = "input"; exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
const exportStatusText = document.createElement("span"); const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text"; exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText);
root.append( root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportRangeSelect, exportRangeSelect,
exportCustomPagesInput, exportCustomPagesInput,
exportButton, exportButton,
batchSubmitButton, batchSubmitButton
exportStatusText
); );
root.append(exportStatusText);
document.body.prepend(root);
document.body.appendChild(root); filterApplyButton.addEventListener("click", () => {
applyNativeControlStyles(document, { void handlers.onApplyFilter();
batchSubmitButton, });
exportButton, sortApplyButton.addEventListener("click", () => {
exportCustomPagesInput, void handlers.onApplySort();
exportRangeSelect
}); });
ensureToolbarMounted(root, document);
exportButton.addEventListener("click", () => { exportButton.addEventListener("click", () => {
void handlers.onExport(); void handlers.onExport();
}); });
@ -106,7 +130,13 @@ export function ensurePluginToolbar(
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
exportStatusText, exportStatusText,
root filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
}); });
}); });
@ -116,7 +146,13 @@ export function ensurePluginToolbar(
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
exportStatusText, exportStatusText,
root filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
@ -151,7 +187,25 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector( exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]' '[data-plugin-export-status="text"]'
) as HTMLElement, ) as HTMLElement,
root filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
personalFilterInput: root.querySelector(
'[data-plugin-filter-personal="input"]'
) as HTMLInputElement,
root,
singleFilterInput: root.querySelector(
'[data-plugin-filter-single="input"]'
) as HTMLInputElement,
sortApplyButton: root.querySelector(
'[data-plugin-sort-apply="button"]'
) as HTMLButtonElement,
sortDirectionSelect: root.querySelector(
'[data-plugin-sort-direction="select"]'
) as HTMLSelectElement,
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom; return toolbarDom;
@ -219,6 +273,12 @@ export function setToolbarBusyState(
[ [
toolbar.batchSubmitButton, toolbar.batchSubmitButton,
toolbar.exportButton, toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,
toolbar.singleFilterInput,
toolbar.personalFilterInput,
toolbar.sortFieldSelect,
toolbar.sortDirectionSelect,
toolbar.exportRangeSelect, toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput toolbar.exportCustomPagesInput
].forEach((element) => { ].forEach((element) => {
@ -237,317 +297,3 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden = toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom"; toolbar.exportRangeSelect.value !== "custom";
} }
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: {
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.exportButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className;
}
[controls.exportButton, 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="button"]:hover:not(:disabled),
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
background-color: #6d1627 !important;
border-color: #6d1627 !important;
}
[data-plugin-export="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="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="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;
}

View File

@ -1,401 +0,0 @@
import {
readMarketListRequestSnapshot,
type MarketListRequestSnapshot
} from "./market-list-request-snapshot";
import {
parseMarketListResponse,
readKnownPaginationNumber
} from "./market-list-row";
import type { MarketExportTarget, MarketRecord } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
interface SilentExportControllerOptions {
document: Document;
fetchImpl?: FetchLike;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
}
type PageSource = "body" | "none" | "url";
const PAGE_NUMBER_KEYS = [
"currentPage",
"page",
"pageNo",
"pageNum",
"page_no",
"page_num"
] as const;
export function createSilentExportController(
options: SilentExportControllerOptions
) {
const fetchImpl = options.fetchImpl ?? defaultFetch;
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[] | null> {
const snapshot = readMarketListRequestSnapshot(options.document);
if (!snapshot) {
return null;
}
const baseRequest = createPagedRequest(snapshot);
if (!baseRequest) {
return null;
}
const mergedRecords = new Map<string, MarketRecord>();
const maxPageCount = target.mode === "count" ? target.pageCount : 200;
let totalPagesHint: number | undefined;
for (let offset = 0; offset < maxPageCount; offset += 1) {
const pageNumber = baseRequest.initialPage + offset;
options.onProgress?.({
currentPage: offset + 1,
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
});
const payload = await fetchPagePayload(fetchImpl, baseRequest, pageNumber);
const parsedResponse = parseMarketListResponse(payload);
if (!parsedResponse) {
return null;
}
totalPagesHint = parsedResponse.totalPages ?? totalPagesHint;
if (parsedResponse.records.length === 0) {
break;
}
parsedResponse.records.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
if (target.mode === "count" && offset + 1 >= target.pageCount) {
break;
}
if (target.mode === "all") {
if (
typeof parsedResponse.totalPages === "number" &&
pageNumber >= parsedResponse.totalPages
) {
break;
}
if (
typeof parsedResponse.pageSize === "number" &&
parsedResponse.records.length < parsedResponse.pageSize
) {
break;
}
}
}
return Array.from(mergedRecords.values());
}
};
}
function createPagedRequest(
snapshot: MarketListRequestSnapshot
): {
initialPage: number;
pageSource: PageSource;
snapshot: MarketListRequestSnapshot;
} {
const bodyPage = readPageFromBody(snapshot.body);
if (bodyPage !== null) {
return {
initialPage: bodyPage,
pageSource: "body",
snapshot
};
}
const urlPage = readPageFromUrl(snapshot.url);
if (urlPage !== null) {
return {
initialPage: urlPage,
pageSource: "url",
snapshot
};
}
return {
initialPage: 1,
pageSource: "none",
snapshot
};
}
async function fetchPagePayload(
fetchImpl: FetchLike,
request: {
pageSource: PageSource;
snapshot: MarketListRequestSnapshot;
},
pageNumber: number
): Promise<unknown> {
const nextUrl =
request.pageSource === "url"
? mutateUrlPage(request.snapshot.url, pageNumber)
: request.snapshot.url;
const nextBody = mutateBodyPage(request.snapshot.body, pageNumber);
const response = await fetchImpl(nextUrl, {
body: nextBody,
credentials: "include",
headers: filterReplayHeaders(request.snapshot.headers, nextBody),
method: request.snapshot.method
});
if (!response.ok) {
throw new Error("静默导出请求失败");
}
return response.json();
}
function readPageFromUrl(url: string): number | null {
try {
const parsedUrl = new URL(url);
for (const key of PAGE_NUMBER_KEYS) {
const value = readNumericString(parsedUrl.searchParams.get(key));
if (value !== null) {
return value;
}
}
} catch {
return null;
}
return null;
}
function mutateUrlPage(url: string, pageNumber: number): string {
try {
const parsedUrl = new URL(url);
for (const key of PAGE_NUMBER_KEYS) {
if (!parsedUrl.searchParams.has(key)) {
continue;
}
parsedUrl.searchParams.set(key, String(pageNumber));
return parsedUrl.toString();
}
parsedUrl.searchParams.set("page", String(pageNumber));
return parsedUrl.toString();
} catch {
return url;
}
}
function readPageFromBody(body: string | undefined): number | null {
const parsedBody = parseBody(body);
if (!parsedBody) {
return null;
}
return readKnownPaginationNumber(parsedBody, "page");
}
function mutateBodyPage(body: string | undefined, pageNumber: number): string | undefined {
if (!body) {
return body;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return body;
}
try {
const parsedJson = JSON.parse(trimmedBody);
if (!replacePageNumberInValue(parsedJson, pageNumber) && isRecord(parsedJson)) {
parsedJson.page = pageNumber;
}
return JSON.stringify(parsedJson);
} catch {
const searchParams = new URLSearchParams(trimmedBody);
for (const key of PAGE_NUMBER_KEYS) {
if (!searchParams.has(key)) {
continue;
}
searchParams.set(key, String(pageNumber));
return searchParams.toString();
}
searchParams.set("page", String(pageNumber));
return searchParams.toString();
}
}
function parseBody(body: string | undefined): Record<string, unknown> | null {
if (!body) {
return null;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return null;
}
try {
const parsedBody = JSON.parse(trimmedBody);
return isRecord(parsedBody) ? parsedBody : null;
} catch {
const searchParams = new URLSearchParams(trimmedBody);
const payload: Record<string, unknown> = {};
searchParams.forEach((value, key) => {
payload[key] = value;
});
return payload;
}
}
function replacePageNumberInValue(value: unknown, pageNumber: number): boolean {
if (!isRecord(value)) {
return false;
}
let replaced = false;
PAGE_NUMBER_KEYS.forEach((key) => {
if (!(key in value)) {
return;
}
value[key] = pageNumber;
replaced = true;
});
if (replaced) {
return true;
}
return Object.values(value).some((entry) => replacePageNumberInValue(entry, pageNumber));
}
function filterReplayHeaders(
headers: Record<string, string> | undefined,
body: string | undefined
): HeadersInit | undefined {
const filteredHeaders = Object.fromEntries(
Object.entries(headers ?? {}).filter(([key]) => {
const normalizedKey = key.toLowerCase();
return normalizedKey !== "content-length" && normalizedKey !== "host";
})
);
if (body) {
if (!hasHeader(filteredHeaders, "accept")) {
filteredHeaders.Accept = "application/json, text/plain, */*";
}
if (!hasHeader(filteredHeaders, "content-type")) {
filteredHeaders["Content-Type"] = "application/json";
}
if (!hasHeader(filteredHeaders, "x-login-source")) {
filteredHeaders["x-login-source"] = "1";
}
if (!hasHeader(filteredHeaders, "agw-js-conv")) {
filteredHeaders["Agw-Js-Conv"] = "str";
}
}
return Object.keys(filteredHeaders).length > 0 ? filteredHeaders : undefined;
}
function hasHeader(headers: Record<string, string>, key: string): boolean {
return Object.keys(headers).some((headerKey) => headerKey.toLowerCase() === key);
}
function readNumericString(value: string | null): number | null {
if (!value) {
return null;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates),
status: incomingRecord.status ?? "idle"
};
}
return {
...existingRecord,
...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord.exportFields,
incomingRecord.exportFields
),
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
hasDirectRatesSource:
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
location: mergeStringValue(existingRecord.location, incomingRecord.location),
price21To60s: mergeStringValue(
existingRecord.price21To60s,
incomingRecord.price21To60s
),
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
status: incomingRecord.status ?? existingRecord.status
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
return hasTextValue(incoming) ? incoming : current;
}
function hasTextValue(value: string | undefined): boolean {
return Boolean(value && value.trim().length > 0);
}

View File

@ -12,10 +12,6 @@ export interface BackendMetrics {
newA3Rate?: string; newA3Rate?: string;
} }
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing"; export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
export interface MarketRowSnapshot { export interface MarketRowSnapshot {
@ -53,7 +49,7 @@ export type MarketExportTarget =
export interface MarketSortState { export interface MarketSortState {
direction: "asc" | "desc"; direction: "asc" | "desc";
field: MarketSortField; field: keyof Required<AfterSearchRates>;
} }
export type MarketApiFailureReason = export type MarketApiFailureReason =
@ -64,7 +60,7 @@ export type MarketApiFailureReason =
export type MarketApiSuccessResult = { export type MarketApiSuccessResult = {
success: true; success: true;
rates: AfterSearchRates; rates: Required<AfterSearchRates>;
}; };
export type MarketApiFailureResult = { export type MarketApiFailureResult = {

View File

@ -3,14 +3,7 @@
"name": "Star Chart Search Enhancer", "name": "Star Chart Search Enhancer",
"version": "0.2.0421.2", "version": "0.2.0421.2",
"description": "Bootstraps the Xingtu creator market content script.", "description": "Bootstraps the Xingtu creator market content script.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
"permissions": ["downloads", "identity", "storage"], "permissions": ["downloads", "identity", "storage"],
"icons": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
},
"host_permissions": [ "host_permissions": [
"http://*/*", "http://*/*",
"https://login-api.intelligrow.cn/*", "https://login-api.intelligrow.cn/*",
@ -18,10 +11,6 @@
"https://*/*" "https://*/*"
], ],
"action": { "action": {
"default_icon": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png"
},
"default_popup": "popup/index.html" "default_popup": "popup/index.html"
}, },
"background": { "background": {
@ -34,7 +23,7 @@
"https://*.xingtu.cn/ad/creator/market*" "https://*.xingtu.cn/ad/creator/market*"
], ],
"js": ["content/index.js"], "js": ["content/index.js"],
"run_at": "document_start" "run_at": "document_idle"
} }
], ],
"web_accessible_resources": [ "web_accessible_resources": [

View File

@ -9,7 +9,7 @@ export interface AuthConfig {
const defaultAuthConfig: AuthConfig = { const defaultAuthConfig: AuthConfig = {
apiResource: "https://talent-search.intelligrow.cn", apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3", appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: false, enableDevAuthPanel: true,
logtoEndpoint: "https://login-api.intelligrow.cn", logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"] scopes: ["openid", "profile", "offline_access", "talent-search:read"]
}; };

View File

@ -76,13 +76,11 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
return [ return [
{ {
a3IncreaseCount: formatDecimalValue( a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
readAverageA3IncreaseCount(row)
),
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt), afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate), afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
cpSearch: formatDecimalValue(row.cp_search), cpSearch: formatDecimalValue(row.cp_search),
cpa3: formatDecimalValue(readCpa3Value(row)), cpa3: formatDecimalValue(row.cpa3),
newA3Rate: formatRateValue(row.avg_new_a3_rate), newA3Rate: formatRateValue(row.avg_new_a3_rate),
starId: row.star_id starId: row.star_id
} }
@ -90,84 +88,6 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
}); });
} }
function readAverageA3IncreaseCount(row: Record<string, unknown>): number | null {
const directAverage = readFiniteNumber(row.avg_a3_increase_cnt);
if (directAverage !== null) {
return directAverage;
}
const totalNewA3 = readTotalNewA3Value(row);
const videoCount =
readFiniteNumber(row.video_count) ?? readNestedVideoCount(row.videos);
if (totalNewA3 === null || videoCount === null || videoCount <= 0) {
return null;
}
return totalNewA3 / videoCount;
}
function readCpa3Value(row: Record<string, unknown>): number | null {
const directCpa3 = readFiniteNumber(row.cpa3);
if (directCpa3 !== null) {
return directCpa3;
}
const totalCost = readFiniteNumber(row.total_estimated_video_cost);
const totalNewA3 = readTotalNewA3Value(row);
if (totalCost === null || totalNewA3 === null || totalNewA3 <= 0) {
return null;
}
return totalCost / totalNewA3;
}
function readTotalNewA3Value(row: Record<string, unknown>): number | null {
const derivedFromTotals = deriveTotalNewA3FromTotals(row);
if (derivedFromTotals !== null) {
return derivedFromTotals;
}
return deriveTotalNewA3FromVideos(row.videos);
}
function deriveTotalNewA3FromTotals(row: Record<string, unknown>): number | null {
const totalPlayCount = readFiniteNumber(row.total_play_cnt);
const averageNewA3Rate = readFiniteNumber(row.avg_new_a3_rate);
if (totalPlayCount === null || averageNewA3Rate === null) {
return null;
}
return totalPlayCount * averageNewA3Rate;
}
function deriveTotalNewA3FromVideos(value: unknown): number | null {
if (!Array.isArray(value)) {
return null;
}
let total = 0;
let hasFiniteValue = false;
value.forEach((video) => {
if (!isRecord(video)) {
return;
}
const newA3 = readFiniteNumber(video.new_a3);
if (newA3 === null) {
return;
}
hasFiniteValue = true;
total += newA3;
});
return hasFiniteValue ? total : null;
}
function readNestedVideoCount(value: unknown): number | null {
return Array.isArray(value) ? value.length : null;
}
function readResponseRows(payload: unknown): unknown[] | null { function readResponseRows(payload: unknown): unknown[] | null {
if (!isRecord(payload) || payload.success !== true) { if (!isRecord(payload) || payload.success !== true) {
return null; return null;
@ -210,8 +130,3 @@ async function defaultFetch(input: string, init?: RequestInit) {
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function readFiniteNumber(value: unknown): number | null {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
}

View File

@ -1,2 +1 @@
export const DEFAULT_BACKEND_METRICS_BASE_URL = export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
"https://talent-search.intelligrow.cn";

View File

@ -1,6 +1,5 @@
import type { BatchPayload } from "../content/market/batch-payload"; import type { BatchPayload } from "../content/market/batch-payload";
import { isAuthResponseMessage } from "./auth-messages"; import { isAuthResponseMessage } from "./auth-messages";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "./batch-submit-config";
interface FetchResponseLike { interface FetchResponseLike {
json(): Promise<unknown>; json(): Promise<unknown>;
@ -17,12 +16,11 @@ type GetAccessTokenLike = () => Promise<string>;
type SendMessageLike = (message: unknown) => Promise<unknown>; type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: { export function createBatchSubmitClient(options: {
baseUrl?: string; baseUrl: string;
fetchImpl?: FetchLike; fetchImpl?: FetchLike;
getAccessToken?: GetAccessTokenLike; getAccessToken?: GetAccessTokenLike;
sendMessage: SendMessageLike; sendMessage: SendMessageLike;
}) { }) {
const baseUrl = options.baseUrl ?? DEFAULT_BATCH_SUBMIT_BASE_URL;
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const getAccessToken = const getAccessToken =
options.getAccessToken ?? (() => readAccessToken(options.sendMessage)); options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
@ -31,7 +29,7 @@ export function createBatchSubmitClient(options: {
async submitBatch(payload: BatchPayload) { async submitBatch(payload: BatchPayload) {
const token = await getAccessToken(); const token = await getAccessToken();
const response = await fetchImpl( const response = await fetchImpl(
buildBatchSubmitUrl(baseUrl), new URL("/api/mock/batches", options.baseUrl).toString(),
{ {
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: { headers: {
@ -50,15 +48,11 @@ export function createBatchSubmitClient(options: {
throw new Error(`batch submit failed: ${response.status}`); throw new Error(`batch submit failed: ${response.status}`);
} }
return readBatchSubmitResponse(await response.json()); return response.json();
} }
}; };
} }
export function buildBatchSubmitUrl(baseUrl: string): string {
return new URL("/api/v1/batch-status/batches", baseUrl).toString();
}
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> { async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
const response = await sendMessage({ type: "auth:get-access-token" }); const response = await sendMessage({ type: "auth:get-access-token" });
@ -73,23 +67,3 @@ async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
return response.value.accessToken; return response.value.accessToken;
} }
function readBatchSubmitResponse(payload: unknown): unknown {
if (!isRecord(payload)) {
throw new Error("batch submit response is invalid");
}
if (payload.success !== true) {
const message =
typeof payload.msg === "string" && payload.msg.trim()
? payload.msg
: "batch submit failed";
throw new Error(message);
}
return "data" in payload ? payload.data : payload;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -1 +0,0 @@
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";

View File

@ -7,7 +7,7 @@ describe("auth-config", () => {
expect(readAuthConfig()).toEqual({ expect(readAuthConfig()).toEqual({
apiResource: "https://talent-search.intelligrow.cn", apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3", appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: false, enableDevAuthPanel: true,
logtoEndpoint: "https://login-api.intelligrow.cn", logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: [ scopes: [
"openid", "openid",

View File

@ -10,14 +10,12 @@ import {
describe("backend-metrics-client", () => { describe("backend-metrics-client", () => {
test("exports the default backend metrics base url", () => { test("exports the default backend metrics base url", () => {
expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe( expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe("http://192.168.31.29:8083");
"https://talent-search.intelligrow.cn"
);
}); });
test("builds the backend search url", () => { test("builds the backend search url", () => {
expect(buildBackendMetricsSearchUrl("https://talent-search.intelligrow.cn")).toBe( expect(buildBackendMetricsSearchUrl("http://192.168.31.29:8083")).toBe(
"https://talent-search.intelligrow.cn/api/v1/history/talents/search" "http://192.168.31.29:8083/api/v1/history/talents/search"
); );
}); });
@ -63,40 +61,6 @@ describe("backend-metrics-client", () => {
]); ]);
}); });
test("derives A3 count and CPA3 from the live aggregate response shape", () => {
expect(
mapBackendMetricsSearchResponse({
data: {
data: [
{
avg_after_view_search_cnt: 25982,
avg_after_view_search_rate: 0.0010872130261527625,
avg_new_a3_rate: 0.11075860229946684,
cp_search: 21.168501270110077,
cpe: 0.630604497471276,
cpm: 23.014670324994974,
star_id: "7021245050621263906",
total_estimated_video_cost: 1100000,
total_play_cnt: 47795601,
video_count: 2
}
]
},
success: true
})
).toEqual([
{
a3IncreaseCount: "2,646,886.98",
afterViewSearchCount: "25,982.00",
afterViewSearchRate: "0.11%",
cpSearch: "21.17",
cpa3: "0.21",
newA3Rate: "11.08%",
starId: "7021245050621263906"
}
]);
});
test("posts star ids with bearer auth when searching backend metrics", async () => { test("posts star ids with bearer auth when searching backend metrics", async () => {
const fetchImpl = async (_input: string, init?: RequestInit) => ({ const fetchImpl = async (_input: string, init?: RequestInit) => ({
json: async () => ({ json: async () => ({
@ -107,7 +71,7 @@ describe("backend-metrics-client", () => {
}), }),
ok: true, ok: true,
status: 200, status: 200,
url: "https://talent-search.intelligrow.cn/api/v1/history/talents/search" url: "http://192.168.31.29:8083/api/v1/history/talents/search"
}); });
const fetchSpy = vi.fn(fetchImpl); const fetchSpy = vi.fn(fetchImpl);
const client = createBackendMetricsClient({ const client = createBackendMetricsClient({
@ -118,7 +82,7 @@ describe("backend-metrics-client", () => {
await client.searchByStarIds(["111", "222"]); await client.searchByStarIds(["111", "222"]);
expect(fetchSpy).toHaveBeenCalledWith( expect(fetchSpy).toHaveBeenCalledWith(
"https://talent-search.intelligrow.cn/api/v1/history/talents/search", "http://192.168.31.29:8083/api/v1/history/talents/search",
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ body: JSON.stringify({
page: 1, page: 1,

View File

@ -164,6 +164,7 @@ describe("background-index", () => {
{ {
payload: { payload: {
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@ -181,10 +182,9 @@ describe("background-index", () => {
expect(submitBatch).toHaveBeenCalledWith( expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
batchName: "批次A" batchId: "批次A-2026-04-22T12:30:00.000Z"
}) })
); );
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
expect(sendResponse).toHaveBeenCalledWith({ expect(sendResponse).toHaveBeenCalledWith({
ok: true, ok: true,
type: "batch:ack", type: "batch:ack",

View File

@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload"; import { createBatchPayload } from "../src/content/market/batch-payload";
describe("batch-payload", () => { describe("batch-payload", () => {
test("builds the batch payload without a client-side batch id", () => { test("builds a batch id from the batch name and timestamp", () => {
const payload = createBatchPayload({ const payload = createBatchPayload({
authState: { authState: {
isAuthenticated: true, isAuthenticated: true,
@ -26,6 +26,7 @@ describe("batch-payload", () => {
{ authorId: "111", authorName: "达人A" }, { authorId: "111", authorName: "达人A" },
{ authorId: "222", authorName: "达人B" } { authorId: "222", authorName: "达人B" }
], ],
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批", batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",

View File

@ -1,13 +1,8 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../src/shared/batch-submit-config";
import { createBatchSubmitClient } from "../src/shared/batch-submit-client"; import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => { describe("batch-submit-client", () => {
test("exports the default batch submit base url", () => {
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
});
test("posts the batch payload with a Bearer token", async () => { test("posts the batch payload with a Bearer token", async () => {
const sendMessage = vi.fn(async () => ({ const sendMessage = vi.fn(async () => ({
ok: true, ok: true,
@ -17,15 +12,7 @@ describe("batch-submit-client", () => {
const fetchImpl = vi.fn(async () => ({ const fetchImpl = vi.fn(async () => ({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ json: async () => ({ acceptedCount: 2, ok: true })
data: {
batch_id: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
status: true,
talent_count: 1
},
msg: "",
success: true
})
})); }));
const client = createBatchSubmitClient({ const client = createBatchSubmitClient({
@ -36,6 +23,7 @@ describe("batch-submit-client", () => {
await client.submitBatch({ await client.submitBatch({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@ -44,10 +32,11 @@ describe("batch-submit-client", () => {
}); });
expect(fetchImpl).toHaveBeenCalledWith( expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/v1/batch-status/batches", "http://127.0.0.1:4319/api/mock/batches",
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@ -63,37 +52,6 @@ describe("batch-submit-client", () => {
); );
}); });
test("throws when the batch submit api returns success false", async () => {
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
data: null,
msg: "duplicate batch id",
success: false
})
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(
client.submitBatch({
authors: [],
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
})
).rejects.toThrow(/duplicate batch id/i);
});
test("throws on unauthorized responses", async () => { test("throws on unauthorized responses", async () => {
const client = createBatchSubmitClient({ const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319", baseUrl: "http://127.0.0.1:4319",
@ -112,6 +70,7 @@ describe("batch-submit-client", () => {
await expect( await expect(
client.submitBatch({ client.submitBatch({
authors: [], authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",

View File

@ -15,13 +15,7 @@ describe("csv-exporter", () => {
"地区", "地区",
"21-60s报价", "21-60s报价",
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率", "个人视频看后搜率"
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",") ].join(",")
); );
}); });
@ -33,19 +27,10 @@ describe("csv-exporter", () => {
authorName: "Alice", authorName: "Alice",
exportFields: { exportFields: {
: "Alice", : "Alice",
: "示例视频",
: "100w", : "100w",
"21-60s报价": "¥450,000" "21-60s报价": "¥450,000"
}, },
status: "success", status: "success",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
rates: { rates: {
singleVideoAfterSearchRate: "0.5%-1%", singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "1% - 3%" personalVideoAfterSearchRate: "1% - 3%"
@ -55,56 +40,11 @@ describe("csv-exporter", () => {
const [headerLine, rowLine] = csv.split("\n"); const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe( expect(headerLine).toBe(
[ ["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
"达人信息", ","
"粉丝数", )
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
); );
expect(rowLine).toBe( expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
);
});
test("omits the representative video column from exported page fields", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
exportFields: {
: "Alice",
: "示例视频",
: "100w"
},
status: "success"
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
[
"达人信息",
"粉丝数",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
}); });
test("escapes commas and quotes", () => { test("escapes commas and quotes", () => {
@ -137,7 +77,7 @@ describe("csv-exporter", () => {
]); ]);
const [, rowLine] = csv.split("\n"); const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,,,,,,,"); expect(rowLine).toBe("123,Alice,,,,");
}); });
test("uses normalized display values in export rows", () => { test("uses normalized display values in export rows", () => {
@ -157,21 +97,4 @@ describe("csv-exporter", () => {
expect(rowLine).toContain("0.5% - 1%"); expect(rowLine).toContain("0.5% - 1%");
expect(rowLine).toContain("0.02% - 0.1%"); expect(rowLine).toContain("0.02% - 0.1%");
}); });
test("emits empty backend metric cells when backend metrics are absent", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
});
}); });

View File

@ -93,95 +93,4 @@ describe("filter-sort-controller", () => {
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]); expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
}); });
test("sorts by backend metric descending and keeps empty values at the end", () => {
const result = applyFilterAndSort(
[
{
...baseRecords[0],
backendMetrics: {
afterViewSearchRate: "0.36%"
}
},
{
...baseRecords[1],
backendMetrics: {
afterViewSearchRate: "1.4%"
}
},
baseRecords[2]
],
{
sort: {
direction: "desc",
field: "afterViewSearchRate"
}
}
);
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
});
test("keeps equal rate buckets in a deterministic order across repeated sorts", () => {
const records: MarketRecord[] = [
{
authorId: "b",
authorName: "Beta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%"
}
},
{
authorId: "a",
authorName: "Alpha",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%"
}
},
{
authorId: "d",
authorName: "Delta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.25% - 0.5%"
}
},
{
authorId: "c",
authorName: "Gamma",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.25% - 0.5%"
}
}
];
const firstResult = applyFilterAndSort(records, {
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
});
const secondResult = applyFilterAndSort([...records].reverse(), {
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
});
expect(firstResult.map((record) => record.authorId)).toEqual([
"a",
"b",
"c",
"d"
]);
expect(secondResult.map((record) => record.authorId)).toEqual([
"a",
"b",
"c",
"d"
]);
});
}); });

View File

@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import manifest from "../src/manifest.json"; import manifest from "../src/manifest.json";
import { createManifest } from "../scripts/manifest.mjs";
describe("manifest", () => { describe("manifest", () => {
test("injects the content script on the www Xingtu market page", () => { test("injects the content script on the www Xingtu market page", () => {
@ -10,16 +9,12 @@ describe("manifest", () => {
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/) expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
]) ])
); );
expect(manifest.content_scripts?.[0]?.run_at).toBe("document_start");
}); });
test("declares the downloads and auth permissions plus background worker", () => { test("declares the downloads and auth permissions plus background worker", () => {
expect(manifest.permissions).toEqual( expect(manifest.permissions).toEqual(
expect.arrayContaining(["downloads", "identity", "storage"]) expect.arrayContaining(["downloads", "identity", "storage"])
); );
expect(manifest.key).toBe(
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB"
);
expect(manifest.host_permissions).toEqual( expect(manifest.host_permissions).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"http://*/*", "http://*/*",
@ -31,38 +26,4 @@ describe("manifest", () => {
expect(manifest.background?.service_worker).toBe("background/index.js"); expect(manifest.background?.service_worker).toBe("background/index.js");
expect(manifest.action?.default_popup).toBe("popup/index.html"); expect(manifest.action?.default_popup).toBe("popup/index.html");
}); });
test("builds a release manifest with narrowed host permissions", () => {
const releaseManifest = createManifest({ target: "release" });
expect(releaseManifest.permissions).toEqual(
expect.arrayContaining(["downloads", "identity", "storage"])
);
expect(releaseManifest.host_permissions).toEqual([
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*"
]);
expect(releaseManifest.host_permissions).not.toEqual(
expect.arrayContaining(["http://*/*", "https://*/*", "http://127.0.0.1:4319/*"])
);
});
test("builds a release manifest with extension icons", () => {
const releaseManifest = createManifest({ target: "release" });
expect(releaseManifest.icons).toEqual({
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
});
expect(releaseManifest.key).toBe(manifest.key);
expect(releaseManifest.action?.default_icon).toEqual({
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png"
});
});
}); });

View File

@ -33,10 +33,12 @@ describe("market-api-client", () => {
}); });
}); });
test("returns a missing-rate failure when the payload omits both rate fields", () => { test("returns a missing-rate failure when the payload omits a required field", () => {
expect( expect(
mapAuthorAseInfoResponse({ mapAuthorAseInfoResponse({
data: {} data: {
avg_search_after_view_rate: "<0.02%"
}
}) })
).toMatchObject({ ).toMatchObject({
success: false, success: false,
@ -44,21 +46,6 @@ describe("market-api-client", () => {
}); });
}); });
test("maps a partially populated payload into partial rates", () => {
expect(
mapAuthorAseInfoResponse({
data: {
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
).toMatchObject({
success: true,
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
});
});
test("returns a request-failed result for non-ok responses", async () => { test("returns a request-failed result for non-ok responses", async () => {
const client = createMarketApiClient({ const client = createMarketApiClient({
fetchImpl: async () => ({ fetchImpl: async () => ({

File diff suppressed because it is too large Load Diff

View File

@ -39,9 +39,6 @@ describe("market-dom-sync", () => {
const table = syncMarketTable(document); const table = syncMarketTable(document);
expect(table).not.toBeNull(); expect(table).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="selection"]')
).not.toBeNull();
expect( expect(
document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]') document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
).not.toBeNull(); ).not.toBeNull();
@ -51,24 +48,9 @@ describe("market-dom-sync", () => {
) )
).not.toBeNull(); ).not.toBeNull();
expect( expect(
document.querySelector('[data-market-header-cell="afterViewSearchRate"]') document.querySelector('[data-market-header-cell="backendMetrics"]')
).not.toBeNull(); ).not.toBeNull();
expect( expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6);
document.querySelector('[data-market-header-cell="afterViewSearchCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="a3IncreaseCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="newA3Rate"]')
).not.toBeNull();
expect(document.querySelector('[data-market-header-cell="cpa3"]')).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="cpSearch"]')
).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18);
expect(table?.headerSelectionCheckbox).not.toBeNull();
expect(table?.rows[0]?.selectionCheckbox).not.toBeNull();
}); });
test("renders loading, success, missing, and failed states", () => { test("renders loading, success, missing, and failed states", () => {
@ -105,15 +87,12 @@ describe("market-dom-sync", () => {
}); });
expect(alphaRow.singleCell.textContent).toBe("加载中..."); expect(alphaRow.singleCell.textContent).toBe("加载中...");
expect(alphaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载中..."); expect(alphaRow.backendMetricsCell.textContent).toBe("加载中...");
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%"); expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%"); expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("0.36%"); expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率");
expect(betaRow.backendMetricsCells.afterViewSearchCount.textContent).toBe("9,689.96"); expect(betaRow.backendMetricsCell.textContent).toContain("0.36%");
expect(betaRow.backendMetricsCells.a3IncreaseCount.textContent).toBe("78,366.22"); expect(betaRow.backendMetricsCell.textContent).toContain("CPA3");
expect(betaRow.backendMetricsCells.newA3Rate.textContent).toBe("3.44%");
expect(betaRow.backendMetricsCells.cpa3.textContent).toBe("1.79");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("14.46");
renderMarketRowState(betaRow, { renderMarketRowState(betaRow, {
authorId: "b", authorId: "b",
@ -125,8 +104,7 @@ describe("market-dom-sync", () => {
personalVideoAfterSearchRate: "0.02 - 0.1%" personalVideoAfterSearchRate: "0.02 - 0.1%"
} }
}); });
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("暂无数据"); expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("暂无数据");
renderMarketRowState(betaRow, { renderMarketRowState(betaRow, {
authorId: "b", authorId: "b",
@ -136,8 +114,7 @@ describe("market-dom-sync", () => {
}); });
expect(betaRow.singleCell.textContent).toBe("加载失败"); expect(betaRow.singleCell.textContent).toBe("加载失败");
expect(betaRow.personalCell.textContent).toBe("加载失败"); expect(betaRow.personalCell.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载失败"); expect(betaRow.backendMetricsCell.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("加载失败");
}); });
test("hides rows outside the visible author ids", () => { test("hides rows outside the visible author ids", () => {
@ -175,59 +152,27 @@ describe("market-dom-sync", () => {
throw new Error("Expected market table"); throw new Error("Expected market table");
} }
expect(table.headerSelectionCheckbox).not.toBeNull(); expect(readRightHeaderTexts()).toEqual([
expect(table.rows[0]?.selectionCheckbox).not.toBeNull(); "21-60s报价",
expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
expect(readSelectionHeaderText()).toBe("全选");
expect(readSelectionRowCheckboxCount()).toBe(2);
expect(readPluginHeaderTexts()).toEqual([
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率", "个人视频看后搜率",
"看后搜率", "秒探指标",
"看后搜数", "操作"
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
]); ]);
expect(
document
.querySelector(".section-wrapper.sticky-header")
?.classList.contains("hide-scrollbar")
).toBe(false);
expect(
document.querySelector(".section-wrapper:not(.sticky-header)")?.classList.contains(
"hide-scrollbar"
)
).toBe(false);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(
(
document.querySelector('[data-testid="plugin-header"]') as HTMLElement
).style.position
).not.toBe("sticky");
const pluginHeaderCells = Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > .header-cell')
) as HTMLElement[];
expect(pluginHeaderCells[0]?.style.width).toBe("160px");
expect(pluginHeaderCells[1]?.style.width).toBe("160px");
expect(pluginHeaderCells[0]?.style.whiteSpace).toBe("nowrap");
expect(pluginHeaderCells[1]?.style.whiteSpace).toBe("nowrap");
expect( expect(
Number.parseFloat( Number.parseFloat(
( (
document.querySelector('[data-testid="right-header"]') as HTMLElement document.querySelector('[data-testid="right-header"]') as HTMLElement
).style.width ).style.width
) )
).toBe(350); ).toBeGreaterThan(350);
expect( expect(
Number.parseFloat( Number.parseFloat(
( (
document.querySelector('[data-testid="right-section"]') as HTMLElement document.querySelector('[data-testid="right-section"]') as HTMLElement
).style.width ).style.width
) )
).toBe(350); ).toBeGreaterThan(350);
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
renderMarketRowState(table.rows[0], { renderMarketRowState(table.rows[0], {
@ -249,21 +194,13 @@ describe("market-dom-sync", () => {
} }
}); });
expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]); expect(readRightRowTexts(0)).toEqual([
expect(readPluginRowTexts(0)).toEqual([ "¥450,000",
"0.5% - 1%", "0.5% - 1%",
"0.02% - 0.1%", "0.02% - 0.1%",
"0.36%", "看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46",
"9,689.96", "下单"
"78,366.22",
"3.44%",
"1.79",
"14.46"
]); ]);
expect(table.rows[0].singleCell.style.width).toBe("160px");
expect(table.rows[0].personalCell.style.width).toBe("160px");
expect(table.rows[0].singleCell.style.whiteSpace).toBe("nowrap");
expect(table.rows[0].personalCell.style.whiteSpace).toBe("nowrap");
applyRowVisibility(table, new Set(["222"])); applyRowVisibility(table, new Set(["222"]));
@ -274,8 +211,7 @@ describe("market-dom-sync", () => {
applyRowOrder(table, ["222", "111"]); applyRowOrder(table, ["222", "111"]);
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]); expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "下单"]); expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]);
expect(readPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
expect(table.rows[0].exportFields).toMatchObject({ expect(table.rows[0].exportFields).toMatchObject({
"21-60s报价": "¥450,000", "21-60s报价": "¥450,000",
"代表视频": "代表视频A", "代表视频": "代表视频A",
@ -283,130 +219,6 @@ describe("market-dom-sync", () => {
}); });
}); });
test("keeps a single scroll hint across repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)).not.toBeNull();
expect(syncMarketTable(document)).not.toBeNull();
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
});
test("keeps reading native author rows after the selection column is injected", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)?.rows.map((row) => row.authorId)).toEqual([
"111",
"222"
]);
const table = syncMarketTable(document);
expect(table?.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(readAuthorNames()).toEqual(["达人 A", "达人 B"]);
});
test("inserts the selection column before the native author column", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(readAuthorSectionColumnKeys()).toEqual(["selection", "author"]);
});
test("keeps the selection cells aligned to the native author row heights", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(readSelectionCellHeights()).toEqual(["120px", "120px"]);
});
test("uses native-like alignment styles for plugin cells", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
const pluginHeaderCell = document.querySelector(
'[data-testid="plugin-header"] [data-market-header-cell="singleVideoAfterSearchRate"]'
) as HTMLElement | null;
const pluginBodyCell = table.rows[0].singleCell;
expect(pluginHeaderCell?.style.display).toBe("flex");
expect(pluginHeaderCell?.style.alignItems).toBe("center");
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
});
test("keeps native-like alignment styles after repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
expect(syncMarketTable(document)).not.toBeNull();
const secondTable = syncMarketTable(document);
if (!secondTable) {
throw new Error("Expected market table");
}
const pluginBodyCell = secondTable.rows[0].singleCell;
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
expect(pluginBodyCell.hasAttribute("data-v-cell-scope")).toBe(true);
});
test("keeps export field alignment when a row is missing the price cell", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithMissingPriceCell();
const initialTable = syncMarketTable(document);
if (!initialTable) {
throw new Error("Expected market table");
}
renderMarketRowState(initialTable.rows[1], {
authorId: "222",
authorName: "达人 B",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
backendMetricsStatus: "success",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table after rerender");
}
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "",
"代表视频": "代表视频B",
"达人信息": "达人 B"
});
expect(table.rows[1].exportFields?.["21-60s报价"]).not.toContain("看后搜率");
});
test("falls back to the market vue state when the DOM has no author id", () => { test("falls back to the market vue state when the DOM has no author id", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
attachMarketVueState([ attachMarketVueState([
@ -432,146 +244,6 @@ describe("market-dom-sync", () => {
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
}); });
test("fills blank export cells from the market vue state", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachMarketVueState([
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "1",
city: "温州",
content_theme_labels_180d: ["有趣剧情创作", "亲情剧集", "情感短剧"],
follower: "4550556",
gender: "2",
interact_rate_within_30d: "0.0572",
link_link_cnt_by_industry: "27029613",
nickname: "达人 A",
play_over_rate_within_30d: "0.263",
price_20_60: "155000",
prospective_20_60_cpm: "21.2362",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "7298854",
star_id: "111"
},
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "0",
city: "杭州",
content_theme_labels_180d: ["搞笑剧情", "大学宿舍趣事", "校园生活"],
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].authorId).toBe("222");
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "¥38,000",
: "7.2%",
: "搞笑剧情 大学宿舍趣事 1+",
: "35%",
: "-",
: "90.1w",
: "达人 B 女 杭州",
: "剧情搞笑",
: "2,077.3w",
CPM: "182.5",
: "20.8w"
});
expect(table.rows[1].rates).toEqual({
singleVideoAfterSearchRate: "0.3%"
});
});
test("finds market rows in nested vue children", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachNestedMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
: "90.1w",
: "20.8w",
: "7.2%"
});
});
test("prefers vue fallback when the price cell is polluted", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithPollutedSecondPrice();
attachMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields?.["21-60s报价"]).toBe("¥38,000");
});
test("falls back to serialized market rows when vue state is unavailable", () => { test("falls back to serialized market rows when vue state is unavailable", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
document.documentElement.setAttribute( document.documentElement.setAttribute(
@ -598,7 +270,6 @@ describe("market-dom-sync", () => {
expect(table.rows[0].rates).toEqual({ expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%" singleVideoAfterSearchRate: "0.02%"
}); });
expect(readMarketPageSignature(document)).toContain("::111|222");
}); });
test("finds the real next-page button in Xingtu pagination", () => { test("finds the real next-page button in Xingtu pagination", () => {
@ -627,16 +298,6 @@ describe("market-dom-sync", () => {
expect(isPageControlDisabled(nextControl)).toBe(false); expect(isPageControlDisabled(nextControl)).toBe(false);
expect(readMarketPageSignature(document)).toContain("1::111|222"); expect(readMarketPageSignature(document)).toContain("1::111|222");
}); });
test("reads market page signature without mutating the page", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const signature = readMarketPageSignature(document);
expect(signature).toContain("::111|222");
expect(document.querySelector('[data-testid="plugin-header"]')).toBeNull();
expect(document.querySelector('[data-testid="plugin-section"]')).toBeNull();
});
}); });
function buildRealMarketGridFixture() { function buildRealMarketGridFixture() {
@ -724,167 +385,8 @@ function buildRealMarketGridFixtureWithoutAuthorIds() {
`; `;
} }
function buildRealMarketGridFixtureWithMissingPriceCell() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;">B</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;"></div>
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRealMarketGridFixtureWithScopedAttributes() {
return buildRealMarketGridFixture()
.replace(
'<div class="header-cell" style="min-width: 190px;">代表视频</div>',
'<div data-v-header-scope class="header-cell" style="min-width: 190px;">代表视频</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频A</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频A</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频B</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频B</div>'
);
}
function buildRichMarketGridFixtureWithBlankSecondRow() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 180px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;">CPM</div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 180px;">
<div class="content-cell" style="height: 120px;"> 1+</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">2,703w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">455.1w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">21.2</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">729.9w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">5.7%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">26.3%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">100%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥155,000</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRichMarketGridFixtureWithPollutedSecondPrice() {
return buildRichMarketGridFixtureWithBlankSecondRow().replace(
'<div class="content-cell" style="height: 120px;"></div>\n </div>\n <div class="content-column" style="min-width: 200px;">',
'<div class="content-cell" style="height: 120px;">看后搜率0.39%看后搜数2,248.33新增A3数0.00新增A3率0%CPA30.00cp_search20.01</div>\n </div>\n <div class="content-column" style="min-width: 200px;">'
);
}
function attachMarketVueState( function attachMarketVueState(
marketList: Array<Record<string, unknown>> marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
) { ) {
const marketRoot = document.querySelector(".base-author-list"); const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) { if (!(marketRoot instanceof HTMLElement)) {
@ -903,40 +405,6 @@ function attachMarketVueState(
}); });
} }
function attachNestedMarketVueState(marketList: Array<Record<string, unknown>>) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Expected market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
$children: [
{
$children: [
{
_setupState: {},
$children: [
{
_setupState: {
__$temp_1: {
marketList
}
},
$children: []
}
]
}
],
_setupState: {}
}
],
_setupState: {}
}
});
}
function readRightHeaderTexts() { function readRightHeaderTexts() {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'), document.querySelectorAll('[data-testid="right-header"] > *'),
@ -944,74 +412,18 @@ function readRightHeaderTexts() {
); );
} }
function readPluginHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
function readRightRowTexts(rowIndex: number) { function readRightRowTexts(rowIndex: number) {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'), document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" (column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
); );
} }
function readPluginRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
);
}
function readScrollHintText() {
return (
document.querySelector('[data-testid="market-scroll-hint"]')?.textContent?.trim() ?? ""
);
}
function readSelectionHeaderText() {
return (
document
.querySelector('[data-market-header-cell="selection"]')
?.textContent?.trim() ?? ""
);
}
function readSelectionRowCheckboxCount() {
return document.querySelectorAll('[data-market-selection-checkbox="row"]').length;
}
function readAuthorNames() { function readAuthorNames() {
const authorColumn = Array.from(
document.querySelectorAll('[data-testid="author-section"] > .content-column')
).find((column) => (column as HTMLElement).querySelector("a")) as Element | null;
return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
);
}
function readAuthorSectionColumnKeys() {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="author-section"] > .content-column'), document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
(column) => { (link) => link.textContent?.trim() ?? ""
const element = column as HTMLElement;
if (element.dataset.marketColumnGroup) {
return element.dataset.marketColumnGroup;
}
return element.querySelector("a") ? "author" : "unknown";
}
);
}
function readSelectionCellHeights() {
return Array.from(
document.querySelectorAll(
'[data-testid="author-section"] [data-market-column-group="selection"] > .content-cell'
),
(cell) => (cell as HTMLElement).style.height
); );
} }
@ -1028,22 +440,3 @@ function readRightActionHiddenStates() {
(cell) => (cell as HTMLElement).hidden (cell) => (cell as HTMLElement).hidden
); );
} }
function readVisualCells(root: Element | null): HTMLElement[] {
if (!root) {
return [];
}
return Array.from(root.querySelectorAll(":scope > .content-cell"))
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
.sort((left, right) => {
const leftOrder = Number(left.style.order || "0");
const rightOrder = Number(right.style.order || "0");
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
return cells.indexOf(left) - cells.indexOf(right);
});
}

View File

@ -1,129 +0,0 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://www.xingtu.cn/ad/creator/market"}
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
describe("market-page-bridge", () => {
beforeEach(() => {
document.body.innerHTML = '<div class="base-author-list"></div>';
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
vi.spyOn(window, "setInterval").mockReturnValue(0 as unknown as number);
});
afterEach(() => {
delete (
window as Window & {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
vi.restoreAllMocks();
vi.resetModules();
});
test("ignores non-search market list responses when capturing the export snapshot", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "推荐达人"
},
star_id: "recommend-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gauthor/demander_get_recommend_author_lists_v2", {
headers: {
Accept: "application/json, text/plain, */*"
},
method: "GET"
});
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.getAttribute("data-sces-market-request-snapshot")
).toBeNull();
});
test("captures the search_for_author_square request for silent export", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "搜索达人"
},
star_id: "search-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gsearch/search_for_author_square", {
body: JSON.stringify({
page_param: {
page: "1"
}
}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
});
await Promise.resolve();
await Promise.resolve();
expect(
JSON.parse(
document.documentElement.getAttribute(
"data-sces-market-request-snapshot"
) ?? "null"
)
).toEqual(
expect.objectContaining({
body: JSON.stringify({
page_param: {
page: "1"
}
}),
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
});
});

View File

@ -53,6 +53,7 @@ describe("mock-protected-api", () => {
const response = await fetch(`${server.baseUrl}/api/mock/batches`, { const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
body: JSON.stringify({ body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }], authors: [{ authorId: "111", authorName: "达人A" }],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A", batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",
@ -70,7 +71,7 @@ describe("mock-protected-api", () => {
await expect(response.json()).resolves.toEqual( await expect(response.json()).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
acceptedCount: 1, acceptedCount: 1,
batchId: null, batchId: "批次A-2026-04-22T12:30:00.000Z",
ok: true, ok: true,
source: "mock-batch-submit" source: "mock-batch-submit"
}) })

View File

@ -1,263 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { createSilentExportController } from "../src/content/market/silent-export-controller";
describe("silent-export-controller", () => {
test("replays exports from the live market page state when the request snapshot attribute is missing", async () => {
document.body.innerHTML = '<div class="base-author-list"></div>';
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
};
marketRoot.__vue__ = {
_setupState: {
__$temp_1: {
reqParams: {
scene_param: {
platform_source: 1
},
page_param: {
limit: "20",
page: "2"
},
search_param: {
seach_type: 3
}
}
}
}
};
const requestedPages: number[] = [];
const requestedHeaders: Array<HeadersInit | undefined> = [];
const requestedUrls: string[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
const pageNo = Number(body.page_param?.page ?? 0);
requestedPages.push(pageNo);
requestedHeaders.push(init?.headers);
requestedUrls.push(url);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(
requestedUrls.every((url) =>
url.endsWith("/gw/api/gsearch/search_for_author_square")
)
).toBe(true);
expect(requestedHeaders).toEqual([
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
},
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
}
]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("replays paged exports when the page number is nested inside the request body", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 2
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number };
};
const pageNo = body.page_param?.page ?? 0;
requestedPages.push(pageNo);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("starts from page 1 when the captured request omits an explicit page number", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
filters: {
keyword: "test"
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const page = body.page ?? 0;
requestedPages.push(page);
return {
json: async () => ({
data: {
marketList: [
{
attribute_datas: {
nickname: `达人${page}`,
price_20_60: page * 1000
},
star_id: String(page)
}
]
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([1, 2]);
expect(records?.map((record) => record.authorId)).toEqual(["1", "2"]);
});
test("accepts snapshot headers that contain non-string values from the live XHR capture", async () => {
document.body.innerHTML = "";
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: "1",
limit: "20"
}
}),
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"x-login-source": 1,
"Agw-Js-Conv": "str"
},
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
requestedPages.push(Number(body.page_param?.page ?? 0));
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "达人1"
},
star_id: "1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 1
});
expect(requestedPages).toEqual([1]);
expect(records?.map((record) => record.authorId)).toEqual(["1"]);
});
});