Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aca116949 | ||
|
|
3a80ef9859 | ||
|
|
1a7b025aee | ||
|
|
b3b916c6bc | ||
|
|
e1cf2970da | ||
|
|
18b9d8eee5 | ||
|
|
48362bd85f | ||
|
|
6e06a67bde | ||
|
|
d7b35d6149 | ||
|
|
f683b1db4f | ||
|
|
66e814b30f | ||
|
|
d302614b99 | ||
|
|
57e4dc72aa | ||
|
|
8fa9fc4469 | ||
|
|
02d9063a11 | ||
|
|
703a095c08 | ||
|
|
75294ca8c7 | ||
|
|
303efcdc8f | ||
|
|
50933af0a6 | ||
|
|
db95a1f565 | ||
|
|
37f7e0b5e6 | ||
|
|
38da39589f | ||
|
|
39c4191a95 | ||
|
|
249e6a5971 | ||
|
|
ca9ce02db5 | ||
|
|
c8287e8d8e | ||
|
|
26ae3bb4b6 | ||
|
|
66bc49d498 | ||
|
|
03c2fe0cc7 | ||
|
|
b0d615ab6c | ||
|
|
1de508f2c7 | ||
|
|
577f6bb0bd | ||
|
|
2e049ef718 | ||
|
|
a5aad4f165 | ||
|
|
02be56888a | ||
|
|
ecfe136628 | ||
|
|
09c106e020 | ||
|
|
3992d4c325 | ||
|
|
b308b49368 | ||
|
|
376c7b510e | ||
|
|
37e29bd6b8 | ||
|
|
4f1f80b79b | ||
|
|
b1f2db8552 | ||
|
|
07d1dffe78 | ||
|
|
fe60253cd3 |
+34
@@ -0,0 +1,34 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release-tag
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
|
||||
- name: test
|
||||
image: node:20-alpine
|
||||
depends_on:
|
||||
- install
|
||||
commands:
|
||||
- npm test
|
||||
|
||||
- name: release
|
||||
image: node:20-alpine
|
||||
depends_on:
|
||||
- test
|
||||
environment:
|
||||
COS_BUCKET: wksgx-1343191620
|
||||
COS_REGION: ap-nanjing
|
||||
COS_SECRET_ID:
|
||||
from_secret: cos_secret_id
|
||||
COS_SECRET_KEY:
|
||||
from_secret: cos_secret_key
|
||||
commands:
|
||||
- npm run release:tag
|
||||
+26
@@ -1,4 +1,30 @@
|
||||
.worktrees/
|
||||
.old-reference/
|
||||
.local/
|
||||
dist/
|
||||
# release/
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
dist-release.pem
|
||||
dist-release.crx
|
||||
|
||||
# Playwright test artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
e2e-tests/
|
||||
playwright.config.js
|
||||
|
||||
# Debug scripts
|
||||
scripts/debug-*.mjs
|
||||
|
||||
# Local debug captures
|
||||
after-export-requests.txt
|
||||
before-export-requests.txt
|
||||
network-after-page2.txt
|
||||
|
||||
# Unrelated local planning artifacts
|
||||
docs/superpowers/plans/2026-04-25-professional-capability-exam-guide.md
|
||||
docs/superpowers/specs/2026-04-25-professional-capability-exam-guide-design.md
|
||||
externaldocs/2026-04-25-专业能力测试冲刺讲义.html
|
||||
externaldocs/2026-04-25-专业能力测试冲刺讲义.pdf
|
||||
|
||||
@@ -1,85 +1,198 @@
|
||||
# Star Chart Search Enhancer
|
||||
# 星图增强插件
|
||||
|
||||
Chrome MV3 extension for the Xingtu creator market page.
|
||||
这是一个供公司内部使用的 Chrome MV3 插件,用于增强巨量星图达人市场页面的使用体验。
|
||||
|
||||
## Development
|
||||
主要功能:
|
||||
|
||||
- 在星图达人列表页补充插件侧数据列
|
||||
- 支持勾选部分达人后导出 CSV
|
||||
- 支持将达人数据提交为批次
|
||||
- 集成 Logto 登录
|
||||
- 支持内部压缩包分发后通过 `Load unpacked` 安装
|
||||
|
||||
当前固定扩展 ID:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
---
|
||||
|
||||
## 一、项目目录
|
||||
|
||||
- `src/`
|
||||
- 插件源码
|
||||
- `dist/`
|
||||
- 开发和发布构建产物
|
||||
- `release/`
|
||||
- 打包后的内部交付压缩包
|
||||
- `docs/`
|
||||
- 项目说明文档
|
||||
- `tests/`
|
||||
- 自动化测试
|
||||
- `scripts/`
|
||||
- 构建和打包脚本
|
||||
|
||||
---
|
||||
|
||||
## 二、开发环境
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
开发构建:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `npm run build` 会生成开发版到 `dist/`
|
||||
- 开发版包含本地调试需要的宽权限
|
||||
|
||||
---
|
||||
|
||||
## 三、内部交付构建
|
||||
|
||||
生成内部使用构建:
|
||||
|
||||
```bash
|
||||
npm run build:release
|
||||
```
|
||||
|
||||
生成内部压缩包:
|
||||
|
||||
```bash
|
||||
npm run package:internal
|
||||
```
|
||||
|
||||
生成更新清单:
|
||||
|
||||
```bash
|
||||
npm run write:latest
|
||||
```
|
||||
|
||||
生成结果:
|
||||
|
||||
- 构建目录:`dist/`
|
||||
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
||||
- 更新清单:`release/latest.json`
|
||||
|
||||
说明:
|
||||
|
||||
- 这个压缩包不是给 Chrome 商店上传的
|
||||
- 它是发给公司内部同事使用的交付包
|
||||
- 同事收到后需要解压,再到 `chrome://extensions` 中 `Load unpacked`
|
||||
- COS 发布时,`latest.json` 放在 `star-chart-search-enhancer/latest.json`,ZIP 和 PDF 放在对应版本目录下
|
||||
- 打 tag 后会触发 Drone 发布,推荐格式:`0.MMDD.N`
|
||||
|
||||
---
|
||||
|
||||
## 四、插件安装方式
|
||||
|
||||
本项目当前采用公司内部手工安装方式:
|
||||
|
||||
1. 解压内部压缩包
|
||||
2. 打开 `chrome://extensions`
|
||||
3. 打开右上角 `开发者模式`
|
||||
4. 点击 `加载已解压的扩展程序`
|
||||
5. 选择解压后的 `dist/` 文件夹
|
||||
|
||||
安装后请确认扩展 ID 是:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
---
|
||||
|
||||
## 五、认证与配置
|
||||
|
||||
插件使用 Logto 登录。
|
||||
|
||||
认证配置位于:
|
||||
|
||||
- `src/shared/auth-config.ts`
|
||||
|
||||
当前主要配置包括:
|
||||
|
||||
- `logtoEndpoint`
|
||||
- `appId`
|
||||
- `apiResource`
|
||||
- `scopes`
|
||||
|
||||
说明:
|
||||
|
||||
- popup 中的开发调试面板默认关闭
|
||||
- 如果需要本地调试受保护接口,可以手动把 `enableDevAuthPanel` 改为 `true`
|
||||
|
||||
---
|
||||
|
||||
## 六、批次提交说明
|
||||
|
||||
提交批次时,前端当前会提交以下核心字段:
|
||||
|
||||
- `logtoUserId`
|
||||
- `creatorName`
|
||||
- `resource`
|
||||
- `batchName`
|
||||
- `createdAt`
|
||||
- `authors`
|
||||
|
||||
说明:
|
||||
|
||||
- `batchId` 不再由前端生成
|
||||
- 现在由后端生成 7 位数字批次 ID
|
||||
|
||||
---
|
||||
|
||||
## 七、重要文档
|
||||
|
||||
给内部同事的安装与使用说明:
|
||||
|
||||
- `docs/aigc-user-guide.md`
|
||||
|
||||
内部压缩包分发说明:
|
||||
|
||||
- `docs/internal-extension-distribution.md`
|
||||
|
||||
---
|
||||
|
||||
## 八、常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run build
|
||||
npm run build:release
|
||||
npm run package:internal
|
||||
```
|
||||
|
||||
## Load The Extension
|
||||
---
|
||||
|
||||
1. Run `npm run build`
|
||||
2. Open `chrome://extensions`
|
||||
3. Enable developer mode
|
||||
4. Choose `Load unpacked`
|
||||
5. Select the `dist/` directory
|
||||
## 九、维护注意事项
|
||||
|
||||
## Current Scope
|
||||
- 扩展 ID 已通过 `manifest.key` 固定
|
||||
- 不要泄露本地私钥文件 `.local/extension-key.pem`
|
||||
- 如果后端地址发生变化,需要同步更新:
|
||||
- `scripts/manifest.mjs`
|
||||
- 对应后端配置文件
|
||||
- 相关文档
|
||||
|
||||
- Adds two after-search-rate columns to the Xingtu market list
|
||||
- Adds a popup-based Logto auth entry
|
||||
- Hydrates the current page immediately
|
||||
- Provides plugin-owned filter, sort, and CSV export controls
|
||||
- Gates the market tools until auth is available
|
||||
- Triggers full-scan flow only when filter, sort, or export is used
|
||||
---
|
||||
|
||||
## Auth Configuration
|
||||
## 十、当前状态
|
||||
|
||||
The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`.
|
||||
Replace these before real sign-in testing:
|
||||
当前项目已经支持:
|
||||
|
||||
- `logtoEndpoint`
|
||||
- `appId`
|
||||
- `apiResource`
|
||||
- Any extra scopes beyond `openid`, `profile`, and `offline_access`
|
||||
|
||||
The popup dev panel is controlled by `enableDevAuthPanel`.
|
||||
|
||||
## Popup Behavior
|
||||
|
||||
1. Load the unpacked extension from `dist/`
|
||||
2. Click the extension icon
|
||||
3. Confirm the popup shows `登录 Logto` when unauthenticated
|
||||
4. After real Logto config is added, use the popup to sign in and sign out
|
||||
|
||||
## Protected API Mock Test
|
||||
|
||||
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
|
||||
2. Run `npm run mock:protected-api`
|
||||
3. Run `npm run build`
|
||||
4. Reload the unpacked extension from `dist/`
|
||||
5. Open the popup and log in
|
||||
6. Click `测试受保护接口`
|
||||
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
|
||||
|
||||
## Batch Submit Mock Test
|
||||
|
||||
1. Run `npm run mock:protected-api`
|
||||
2. Run `npm run build`
|
||||
3. Reload the unpacked extension from `dist/`
|
||||
4. Open `https://xingtu.cn/ad/creator/market`
|
||||
5. Choose an export range in the plugin toolbar
|
||||
6. Click `提交批次`
|
||||
7. Enter a batch name in the browser prompt
|
||||
8. Confirm the toolbar shows `批次提交成功`
|
||||
9. Confirm the mock batch response accepts the payload and reports the submitted `batchId`
|
||||
|
||||
## Market Auth Gate
|
||||
|
||||
When the market page is opened without a valid auth state, the content script renders
|
||||
`请先登录插件` and does not boot the filter, sort, or export toolbar.
|
||||
|
||||
## Manual Verification
|
||||
|
||||
1. Load the unpacked extension from `dist/`
|
||||
2. Open `https://xingtu.cn/ad/creator/market`
|
||||
3. Confirm the page shows the auth gate until login is available
|
||||
4. After authentication is wired, confirm the two new columns appear
|
||||
5. Confirm current-page rows move through loading and then render values or failure states
|
||||
6. Apply a threshold filter and confirm the list hides unmatched rows
|
||||
7. Apply a sort and confirm row order changes
|
||||
8. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
||||
- 新固定扩展 ID
|
||||
- 内部压缩包分发
|
||||
- 自定义批次名称弹窗
|
||||
- 后台静默导出
|
||||
- 批次提交不再由前端生成 `batchId`
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
# 星图增强插件使用说明
|
||||
|
||||
适用对象:AIGC 部门同事,默认不需要任何编程基础。
|
||||
|
||||
这份说明会教你:
|
||||
|
||||
- 如何安装插件
|
||||
- 如何登录插件
|
||||
- 如何在星图页面使用导出和提交批次功能
|
||||
- 如何更新插件
|
||||
- 遇到问题时应该怎么处理
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么
|
||||
|
||||
这是一个给巨量星图达人列表页使用的 Chrome 插件。
|
||||
|
||||
它的主要作用是:
|
||||
|
||||
- 在星图达人列表页面提供增强数据
|
||||
- 支持勾选部分达人后导出 CSV
|
||||
- 支持把达人列表提交为批次
|
||||
|
||||
插件名称:
|
||||
|
||||
- `Star Chart Search Enhancer`
|
||||
|
||||
当前固定扩展 ID:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
如果你在 Chrome 扩展页里看到这个 ID,说明装的是正确版本。
|
||||
|
||||
---
|
||||
|
||||
## 二、安装前准备
|
||||
|
||||
开始前请先确认:
|
||||
|
||||
1. 你使用的是 `Google Chrome` 浏览器。
|
||||
2. 你已经拿到了同事发给你的插件压缩包:
|
||||
- `star-chart-search-enhancer-internal.zip`
|
||||
3. 你可以正常访问:
|
||||
- 巨量星图
|
||||
- 公司登录系统
|
||||
|
||||
注意:
|
||||
|
||||
- 这个插件不是从 Chrome 网上应用店安装的。
|
||||
- 你必须手动解压并加载。
|
||||
|
||||
---
|
||||
|
||||
## 三、第一次安装
|
||||
|
||||
### 第 1 步:解压压缩包
|
||||
|
||||
找到收到的压缩包:
|
||||
|
||||
- `star-chart-search-enhancer-internal.zip`
|
||||
|
||||
右键解压。
|
||||
|
||||
解压后你会得到一个文件夹。请记住这个文件夹的位置,不要删除。
|
||||
|
||||
建议放在:
|
||||
|
||||
- 桌面
|
||||
- 下载目录
|
||||
- 或者一个不会随手清理掉的位置
|
||||
|
||||
不要放在:
|
||||
|
||||
- 临时目录
|
||||
- 会被自动清理的文件夹
|
||||
|
||||
原因:
|
||||
|
||||
- Chrome 加载的是这个文件夹本身
|
||||
- 如果文件夹被删了,插件就失效了
|
||||
|
||||
### 第 2 步:打开 Chrome 扩展页面
|
||||
|
||||
在 Chrome 地址栏输入:
|
||||
|
||||
```text
|
||||
chrome://extensions
|
||||
```
|
||||
|
||||
然后按回车。
|
||||
|
||||
### 第 3 步:打开开发者模式
|
||||
|
||||
在扩展页面右上角,找到:
|
||||
|
||||
- `开发者模式`
|
||||
|
||||
把它打开。
|
||||
|
||||
### 第 4 步:加载插件
|
||||
|
||||
点击左上角或页面上的:
|
||||
|
||||
- `加载已解压的扩展程序`
|
||||
|
||||
然后选择刚才解压出来的那个文件夹。
|
||||
|
||||
选择后,Chrome 会自动加载插件。
|
||||
|
||||
### 第 5 步:确认是否安装成功
|
||||
|
||||
在扩展列表中找到:
|
||||
|
||||
- `Star Chart Search Enhancer`
|
||||
|
||||
点击 `详情` 后,确认扩展 ID 是:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
如果是这个 ID,说明安装的是正确版本。
|
||||
|
||||
---
|
||||
|
||||
## 四、固定到浏览器工具栏
|
||||
|
||||
为了后续登录方便,建议把插件固定到工具栏。
|
||||
|
||||
操作方法:
|
||||
|
||||
1. 点击 Chrome 右上角的拼图图标
|
||||
2. 找到 `Star Chart Search Enhancer`
|
||||
3. 点击右边的图钉
|
||||
|
||||
固定后,浏览器右上角就能直接看到插件图标。
|
||||
|
||||
---
|
||||
|
||||
## 五、如何登录
|
||||
|
||||
### 第 1 步:打开插件弹窗
|
||||
|
||||
点击 Chrome 右上角工具栏中的插件图标。
|
||||
|
||||
### 第 2 步:点击登录
|
||||
|
||||
如果当前未登录,你会看到登录按钮。
|
||||
|
||||
点击:
|
||||
|
||||
- `登录 Logto`
|
||||
|
||||
### 第 3 步:完成登录
|
||||
|
||||
系统会自动跳转到公司登录流程。
|
||||
|
||||
按页面提示完成登录。
|
||||
|
||||
### 第 4 步:确认登录成功
|
||||
|
||||
登录成功后,再次点击插件图标。
|
||||
|
||||
你会看到:
|
||||
|
||||
- 已登录状态
|
||||
- 你的用户名信息
|
||||
- 退出登录按钮
|
||||
|
||||
如果这里能正常显示,说明登录已经成功。
|
||||
|
||||
---
|
||||
|
||||
## 六、进入星图页面
|
||||
|
||||
插件只在巨量星图达人市场页面生效。
|
||||
|
||||
请打开类似下面的页面:
|
||||
|
||||
```text
|
||||
https://xingtu.cn/ad/creator/market
|
||||
```
|
||||
|
||||
进入后,等待页面加载完成。
|
||||
|
||||
如果你已经登录插件,页面上会出现插件自己的工具栏和增强列。
|
||||
|
||||
---
|
||||
|
||||
## 七、页面上你会看到什么
|
||||
|
||||
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
|
||||
|
||||
- `导出选中达人数据` 按钮
|
||||
- `提交批次` 按钮
|
||||
- 状态提示文字
|
||||
- 每一行达人前面的勾选框
|
||||
|
||||
其中:
|
||||
|
||||
- `导出选中达人数据`:导出已勾选达人,包含内容数据、效果预估、画像等维度
|
||||
- `提交批次`:把当前选择的数据提交为批次
|
||||
|
||||
---
|
||||
|
||||
## 八、如何选择达人
|
||||
|
||||
页面上每个达人前面都有一个勾选框。
|
||||
|
||||
你可以:
|
||||
|
||||
- 单独勾选某几个达人
|
||||
- 勾选表头复选框,快速全选当前页
|
||||
|
||||
规则说明:
|
||||
|
||||
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
|
||||
- 导出达人数据必须先勾选达人
|
||||
- 提交批次时,如果你没有勾选任何达人,则默认处理当前列表范围内的达人
|
||||
|
||||
---
|
||||
|
||||
## 九、导出达人数据 CSV 的方法
|
||||
|
||||
导出达人数据 CSV 用来导出内容数据、效果预估、画像和秒思 api 数据。
|
||||
|
||||
### 1. 勾选达人
|
||||
|
||||
导出达人数据必须先勾选达人。
|
||||
|
||||
原因:
|
||||
|
||||
- 导出达人数据会额外读取达人详情页数据
|
||||
- 为了避免请求太多,只处理你勾选的达人
|
||||
|
||||
### 2. 可选:选择需要导出的字段
|
||||
|
||||
如果只想导出一部分字段,点击:
|
||||
|
||||
- `选择字段`
|
||||
|
||||
在弹出的窗口里:
|
||||
|
||||
1. 勾选需要的字段
|
||||
2. 取消不需要的字段
|
||||
3. 点击 `保存`
|
||||
|
||||
基础字段会固定保留,例如:
|
||||
|
||||
- 达人ID
|
||||
- 达人名称
|
||||
- 导出状态
|
||||
- 失败原因
|
||||
|
||||
保存后,下次再导出 CSV,会自动沿用这次勾选结果,不需要重新勾选。
|
||||
|
||||
### 3. 点击导出
|
||||
|
||||
点击:
|
||||
|
||||
- `导出选中达人数据`
|
||||
|
||||
如果已经有达人星图 ID 列表,也可以点击:
|
||||
|
||||
- `按星图ID导出`
|
||||
|
||||
然后把达人 ID 粘贴进去,每行一个。
|
||||
|
||||
### 4. 导出内容
|
||||
|
||||
CSV 会包含:
|
||||
|
||||
- 内容数据
|
||||
- 效果预估
|
||||
- 画像
|
||||
- 秒思 api 数据
|
||||
|
||||
### 5. 等待导出完成
|
||||
|
||||
页面上会显示状态,例如:
|
||||
|
||||
- `导出中...`
|
||||
|
||||
导出完成后,浏览器会自动下载 CSV 文件。
|
||||
|
||||
### 5. 去哪里找文件
|
||||
|
||||
通常会在浏览器默认下载目录里找到。
|
||||
|
||||
如果你不确定下载到了哪里:
|
||||
|
||||
1. 打开 Chrome 下载列表
|
||||
2. 找到最新下载的 CSV 文件
|
||||
|
||||
---
|
||||
|
||||
## 十一、提交批次的方法
|
||||
|
||||
### 1. 如果只想提交部分达人
|
||||
|
||||
先勾选想提交的达人。
|
||||
|
||||
### 2. 点击提交
|
||||
|
||||
点击:
|
||||
|
||||
- `提交批次`
|
||||
|
||||
### 3. 输入批次名称
|
||||
|
||||
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
|
||||
|
||||
你会看到:
|
||||
|
||||
- 标题
|
||||
- 输入框
|
||||
- `取消`
|
||||
- `确认提交`
|
||||
|
||||
建议批次名称写得清楚一些,例如:
|
||||
|
||||
- `618达人筛选第一批`
|
||||
- `食品饮料-KOL测试批次`
|
||||
- `5月女装达人候选`
|
||||
|
||||
### 4. 确认提交
|
||||
|
||||
输入后点击:
|
||||
|
||||
- `确认提交`
|
||||
|
||||
也可以直接按回车提交。
|
||||
|
||||
### 5. 提交成功的提示
|
||||
|
||||
如果成功,页面状态会显示:
|
||||
|
||||
- `批次提交成功`
|
||||
|
||||
如果失败,会看到错误提示。
|
||||
|
||||
---
|
||||
|
||||
## 十二、批次名称填写建议
|
||||
|
||||
建议使用容易看懂的命名方式:
|
||||
|
||||
- 时间 + 主题
|
||||
- 项目 + 人群
|
||||
- 场景 + 顺序编号
|
||||
|
||||
推荐示例:
|
||||
|
||||
- `2026-05-电商零食达人初筛`
|
||||
- `AIGC视频合作达人第一批`
|
||||
- `母婴品类候选达人-第1批`
|
||||
|
||||
不推荐示例:
|
||||
|
||||
- `测试`
|
||||
- `aaa`
|
||||
- `批次1`
|
||||
|
||||
原因:
|
||||
|
||||
- 后续回看时不容易分辨
|
||||
- 团队协作时不方便沟通
|
||||
|
||||
---
|
||||
|
||||
## 十三、如何更新插件
|
||||
|
||||
插件弹窗会检查是否有新版本。
|
||||
|
||||
### 方法一:从插件弹窗下载新版本
|
||||
|
||||
1. 点击浏览器右上角的插件图标
|
||||
2. 查看 `版本更新` 区域
|
||||
3. 如果提示发现新版本,点击:
|
||||
- `下载更新包`
|
||||
- `下载使用说明`
|
||||
4. 解压下载到的新版本 zip
|
||||
5. 打开:
|
||||
- `chrome://extensions`
|
||||
6. 找到:
|
||||
- `Star Chart Search Enhancer`
|
||||
7. 点击:
|
||||
- `重新加载`
|
||||
|
||||
如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。
|
||||
|
||||
### 方法二:收到压缩包后手动更新
|
||||
|
||||
当你收到新的插件压缩包时,不需要重新从零安装。
|
||||
|
||||
按照下面做:
|
||||
|
||||
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
|
||||
2. 打开:
|
||||
- `chrome://extensions`
|
||||
3. 找到:
|
||||
- `Star Chart Search Enhancer`
|
||||
4. 点击:
|
||||
- `重新加载`
|
||||
|
||||
### 方法三:重新解压到新文件夹再重新加载
|
||||
|
||||
1. 解压新的压缩包
|
||||
2. 打开:
|
||||
- `chrome://extensions`
|
||||
3. 如果扩展还在,先看它当前对应的文件夹是否还是旧目录
|
||||
4. 如有需要,移除旧插件,再重新 `加载已解压的扩展程序`
|
||||
|
||||
更新后请确认扩展 ID 仍然是:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
如果 ID 不是这个,说明装错版本了。
|
||||
|
||||
---
|
||||
|
||||
## 十四、常见问题
|
||||
|
||||
### 1. 看不到插件按钮
|
||||
|
||||
处理方法:
|
||||
|
||||
1. 点击浏览器右上角拼图图标
|
||||
2. 找到 `Star Chart Search Enhancer`
|
||||
3. 点击图钉固定
|
||||
|
||||
### 2. 打开星图页面后没有出现插件工具栏
|
||||
|
||||
先检查:
|
||||
|
||||
1. 是否已经登录插件
|
||||
2. 是否打开的是星图达人市场页
|
||||
3. 插件是否启用
|
||||
|
||||
如果还不行:
|
||||
|
||||
1. 打开 `chrome://extensions`
|
||||
2. 找到插件
|
||||
3. 点击 `重新加载`
|
||||
4. 刷新星图页面
|
||||
|
||||
### 3. 点击登录后没反应或登录失败
|
||||
|
||||
先尝试:
|
||||
|
||||
1. 关闭登录弹窗
|
||||
2. 再次点击插件图标
|
||||
3. 重新登录
|
||||
|
||||
如果仍然失败,请把下面信息发给维护同事:
|
||||
|
||||
- 出错时间
|
||||
- 浏览器截图
|
||||
- 是否能打开星图
|
||||
- 是否能看到插件图标
|
||||
|
||||
### 4. 提交批次失败
|
||||
|
||||
请先确认:
|
||||
|
||||
1. 你已经登录插件
|
||||
2. 批次名称不是空的
|
||||
3. 当前页面数据已经加载出来
|
||||
|
||||
如果还是失败,请把:
|
||||
|
||||
- 页面提示内容
|
||||
- 提交时的截图
|
||||
- 使用的批次名称
|
||||
|
||||
发给维护同事。
|
||||
|
||||
### 5. 导出 CSV 没下载
|
||||
|
||||
先检查:
|
||||
|
||||
1. 浏览器是否拦截下载
|
||||
2. 默认下载目录里是否已有文件
|
||||
3. Chrome 下载列表里是否能看到记录
|
||||
|
||||
### 6. 扩展列表里出现两个同名插件
|
||||
|
||||
正确处理方式:
|
||||
|
||||
1. 打开 `chrome://extensions`
|
||||
2. 查看两个扩展的 ID
|
||||
3. 保留:
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
4. 删除不是这个 ID 的旧版本
|
||||
|
||||
---
|
||||
|
||||
## 十四、建议的日常使用流程
|
||||
|
||||
推荐按下面顺序使用:
|
||||
|
||||
1. 打开 Chrome
|
||||
2. 确认插件已启用
|
||||
3. 点击插件图标确认登录状态
|
||||
4. 打开星图达人市场页
|
||||
5. 等待页面数据加载完成
|
||||
6. 先勾选需要的人
|
||||
7. 需要表格时点 `导出选中达人数据`
|
||||
8. 需要进入后续流程时点 `提交批次`
|
||||
|
||||
---
|
||||
|
||||
## 十五、遇到问题时请这样反馈
|
||||
|
||||
如果需要找维护同事帮忙,请尽量一次性提供以下信息:
|
||||
|
||||
- 你在做什么操作
|
||||
- 出问题的时间
|
||||
- 页面截图
|
||||
- 浏览器里看到的提示文字
|
||||
- 扩展 ID 是否为 `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
这样能更快定位问题。
|
||||
|
||||
---
|
||||
|
||||
## 十六、给安装同事的一句话版本
|
||||
|
||||
如果你要把最短说明发给同事,可以直接复制下面这段:
|
||||
|
||||
```text
|
||||
1. 解压收到的插件压缩包
|
||||
2. 打开 chrome://extensions
|
||||
3. 打开右上角“开发者模式”
|
||||
4. 点击“加载已解压的扩展程序”
|
||||
5. 选择解压后的文件夹
|
||||
6. 确认插件名称是 Star Chart Search Enhancer
|
||||
7. 确认扩展 ID 是 pkjopdibdnomhogjheclhnknmejccffg
|
||||
8. 点击右上角插件图标完成登录
|
||||
9. 打开巨量星图达人市场页面开始使用
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Internal Extension Distribution
|
||||
|
||||
## Fixed Extension ID
|
||||
|
||||
- Manifest key is fixed in the project.
|
||||
- The unpacked extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
||||
- Update Logto with:
|
||||
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/callback`
|
||||
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/`
|
||||
- `chrome-extension://pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
## Internal Package
|
||||
|
||||
1. Run `npm test`.
|
||||
2. Run `npm run package:internal`.
|
||||
3. Run `npm run write:latest`.
|
||||
4. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
|
||||
|
||||
## COS Update Manifest
|
||||
|
||||
The popup checks `src/shared/update-config.ts` for the update manifest URL.
|
||||
|
||||
Before publishing the COS-based update flow:
|
||||
|
||||
1. Upload these files to COS:
|
||||
- `star-chart-search-enhancer/latest.json`
|
||||
- `star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
|
||||
- `star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
|
||||
2. Make the COS path publicly readable.
|
||||
3. Replace the placeholder `UPDATE_MANIFEST_URL` in `src/shared/update-config.ts` if your COS bucket changes.
|
||||
4. Rebuild and package the extension.
|
||||
|
||||
The release manifest can be generated with a real public base URL:
|
||||
|
||||
```bash
|
||||
UPDATE_PUBLIC_BASE_URL="https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/<version>" npm run write:latest
|
||||
```
|
||||
|
||||
Quick access check:
|
||||
|
||||
```bash
|
||||
curl -I https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json
|
||||
```
|
||||
|
||||
## Drone Release Flow
|
||||
|
||||
Tag the repo to trigger the release pipeline:
|
||||
|
||||
```bash
|
||||
git tag 0.0525.1
|
||||
git push origin 0.0525.1
|
||||
```
|
||||
|
||||
The Drone job will:
|
||||
|
||||
1. Run `npm ci`.
|
||||
2. Run `npm test`.
|
||||
3. Run `npm run release:tag`.
|
||||
4. Build the release bundle.
|
||||
5. Write `release/latest.json`.
|
||||
6. Upload `latest.json`, the ZIP, and the PDF to COS.
|
||||
|
||||
Drone secrets required:
|
||||
|
||||
- `cos_secret_id`
|
||||
- `cos_secret_key`
|
||||
|
||||
The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`.
|
||||
|
||||
## Coworker Install Steps
|
||||
|
||||
1. Unzip `star-chart-search-enhancer-internal.zip`.
|
||||
2. Open `chrome://extensions`.
|
||||
3. Enable developer mode.
|
||||
4. Click `Load unpacked`.
|
||||
5. Select the unzipped `dist/` folder.
|
||||
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `.local/extension-key.pem` private and backed up internally.
|
||||
- Do not commit or share the private key with people who only need to install the extension.
|
||||
- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging.
|
||||
@@ -0,0 +1,332 @@
|
||||
# Market Audience Profile Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a selected-creators-only `导出画像CSV` flow that exports current market columns plus detail-page "连接用户" audience profile distributions.
|
||||
|
||||
**Architecture:** Keep the existing CSV export untouched and add a separate profile export path. The content controller reuses current selection and market row hydration, loads one selected creator profile at a time through a focused detail-page profile client, then writes a separate CSV with structured audience columns.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content scripts, Xingtu authenticated pages, Vitest, jsdom, tsup
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/background/auth/controller.ts`
|
||||
- Keep token-readable auth state behavior from the previous fix.
|
||||
- Modify: `src/background/auth/state.ts`
|
||||
- Keep logged-out `lastError` support from the previous fix.
|
||||
- Modify: `tests/background-auth-controller.test.ts`
|
||||
- Keep token-expired regression coverage.
|
||||
- Modify: `src/content/market/auth-gate.ts`
|
||||
- Render expired-login text when auth state carries a token-expired error.
|
||||
- Modify: `src/content/index.ts`
|
||||
- Pass auth failure text into the market auth gate if needed.
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Add a `导出画像CSV` button and handler.
|
||||
- Create: `src/content/market/audience-profile-types.ts`
|
||||
- Define normalized distribution and export-row types.
|
||||
- Create: `src/content/market/audience-profile-client.ts`
|
||||
- Load one creator detail page and extract normalized audience profile data.
|
||||
- Create: `src/content/market/audience-profile-csv.ts`
|
||||
- Build CSV columns from market records plus profile distributions.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Add selected-only profile export flow and serial profile loading.
|
||||
- Test: `tests/market-auth-gating.test.ts`
|
||||
- Verify expired-login text.
|
||||
- Test: `tests/plugin-toolbar.test.ts`
|
||||
- Verify new toolbar button wiring.
|
||||
- Test: `tests/audience-profile-csv.test.ts`
|
||||
- Verify structured CSV column expansion.
|
||||
- Test: `tests/audience-profile-client.test.ts`
|
||||
- Verify parser behavior against representative detail-page payload/state shapes.
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
- Verify selected-only export behavior and failure handling.
|
||||
|
||||
## Task 1: Expired Login Message
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/auth-gate.ts`
|
||||
- Modify: `src/content/index.ts`
|
||||
- Test: `tests/market-auth-gating.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing auth gate test**
|
||||
|
||||
Add a test where `sendAuthMessage` returns:
|
||||
|
||||
```ts
|
||||
{
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: false,
|
||||
lastError: "Token 已过期"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Assert the page shows `登录已过期,请重新登录`.
|
||||
|
||||
- [ ] **Step 2: Run the failing test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/market-auth-gating.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the gate only renders `请先登录插件`.
|
||||
|
||||
- [ ] **Step 3: Implement minimal auth gate text support**
|
||||
|
||||
Update `renderMarketAuthGate` to accept an optional message string and render it instead of the default title. Update `bootContentScript` to pass `登录已过期,请重新登录` when `lastError` contains `token` or `过期`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/market-auth-gating.test.ts tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: Toolbar Button
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Test: `tests/plugin-toolbar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing toolbar tests**
|
||||
|
||||
Create tests that:
|
||||
|
||||
- render the toolbar
|
||||
- assert a button with text `导出画像CSV` exists
|
||||
- click it and assert `onExportAudienceProfile` was called
|
||||
- assert `setToolbarBusyState` disables the new button
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/plugin-toolbar.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the button and handler do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement toolbar support**
|
||||
|
||||
Add `onExportAudienceProfile` to `PluginToolbarHandlers`, add `audienceProfileExportButton` to `PluginToolbarDom`, render the new button, wire click handling, and include it in busy-state disabling.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/plugin-toolbar.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Profile CSV Builder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/audience-profile-types.ts`
|
||||
- Create: `src/content/market/audience-profile-csv.ts`
|
||||
- Test: `tests/audience-profile-csv.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing CSV tests**
|
||||
|
||||
Define a sample market record and sample profile:
|
||||
|
||||
```ts
|
||||
const profile = {
|
||||
status: "success",
|
||||
gender: [
|
||||
{ label: "男性", value: "40.6%" },
|
||||
{ label: "女性", value: "59.4%" }
|
||||
],
|
||||
age: [{ label: "18-23", value: "28.6%" }],
|
||||
province: [{ label: "广东", value: "15%" }],
|
||||
regionTop: [{ label: "北京", value: "15%" }],
|
||||
cityTier: [{ label: "一线", value: "20%" }],
|
||||
interestTop: [{ label: "亲子", value: "18%" }],
|
||||
crowd: [{ label: "精致妈妈", value: "12%" }]
|
||||
};
|
||||
```
|
||||
|
||||
Assert the CSV contains separate headers like `连接用户-男性占比`, `省份-广东占比`, `地域TOP1名称`, `地域TOP1占比`.
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/audience-profile-csv.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the files do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement CSV builder**
|
||||
|
||||
Create:
|
||||
|
||||
- `AudienceProfileDistributionItem`
|
||||
- `AudienceProfileResult`
|
||||
- `buildAudienceProfileCsv(records, profilesByAuthorId)`
|
||||
|
||||
Reuse `escapeCsvCell` and existing base/rate/backend metric column conventions. Add `画像抓取状态`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/audience-profile-csv.test.ts tests/csv-exporter.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Detail Profile Client and Parser
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/audience-profile-client.ts`
|
||||
- Test: `tests/audience-profile-client.test.ts`
|
||||
|
||||
- [ ] **Step 1: Use a logged-in browser to identify the real data source**
|
||||
|
||||
Run a Playwright probe against an authenticated `https://xingtu.cn/ad/creator/author-homepage/douyin-video/<authorId>` page. Capture only `/gw/api/...` JSON responses and page Vue/ECharts state. Record representative payload/state samples in the test file as small fixtures.
|
||||
|
||||
- [ ] **Step 2: Write failing parser tests**
|
||||
|
||||
Use the captured fixture to assert the parser returns normalized arrays for gender, age, province, region top 10, city tier, interest top 10, and crowd.
|
||||
|
||||
- [ ] **Step 3: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/audience-profile-client.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the client/parser does not exist.
|
||||
|
||||
- [ ] **Step 4: Implement parser and client**
|
||||
|
||||
Implement a small parser first. Then implement the client with injectable dependencies:
|
||||
|
||||
```ts
|
||||
createAudienceProfileClient({
|
||||
fetchDetailPage?: (authorId: string) => Promise<unknown>;
|
||||
readProfileFromPage?: (authorId: string) => Promise<unknown>;
|
||||
})
|
||||
```
|
||||
|
||||
Prefer parsed API JSON. Fall back to page state when API JSON is unavailable.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/audience-profile-client.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 5: Controller Export Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing selected-only export tests**
|
||||
|
||||
Add tests that:
|
||||
|
||||
- select one of two visible rows
|
||||
- click `导出画像CSV`
|
||||
- assert only the selected author profile is requested
|
||||
- assert `onCsvReady` receives a CSV containing profile columns
|
||||
|
||||
- [ ] **Step 2: Write failing no-selection test**
|
||||
|
||||
Assert clicking `导出画像CSV` with no selected rows sets status to `请先勾选需要导出画像的达人` and makes no profile requests.
|
||||
|
||||
- [ ] **Step 3: Write failing partial-failure test**
|
||||
|
||||
Mock two selected profiles where one succeeds and one fails. Assert CSV is still generated with one success row and one `画像抓取状态=失败` row.
|
||||
|
||||
- [ ] **Step 4: Run failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the controller has no profile export flow.
|
||||
|
||||
- [ ] **Step 5: Implement controller flow**
|
||||
|
||||
Add an injected option `loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>`. Add handler:
|
||||
|
||||
- sync selected state from DOM
|
||||
- reject empty selection
|
||||
- hydrate current-page selected records
|
||||
- load profiles serially
|
||||
- cache successful results by author ID
|
||||
- build profile CSV
|
||||
- call `onCsvReady`
|
||||
- update toolbar progress/status
|
||||
|
||||
- [ ] **Step 6: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/market-content-entry.test.ts tests/audience-profile-csv.test.ts tests/plugin-toolbar.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify only if failures identify necessary scoped fixes.
|
||||
|
||||
- [ ] **Step 1: Run focused suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/market-auth-gating.test.ts tests/plugin-toolbar.test.ts tests/audience-profile-csv.test.ts tests/audience-profile-client.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual logged-in browser verification**
|
||||
|
||||
Load the built extension in Chrome, select one or two market creators, click `导出画像CSV`, and verify the downloaded CSV contains structured profile columns with detail-page data.
|
||||
@@ -0,0 +1,128 @@
|
||||
# COS Extension Update Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Point the popup update flow at the real COS release bucket and keep the generated release manifest, docs, and tests aligned with that COS-based distribution path.
|
||||
|
||||
**Architecture:** Reuse the existing update-check flow already in `src/shared/update-check.ts`, `src/popup/index.ts`, `src/popup/view.ts`, and `src/background/index.ts`. The only behavior change is the source of truth: the popup should fetch a stable COS-hosted `latest.json`, while `scripts/write-latest-manifest.mjs` should keep generating versioned asset URLs under the COS release folder. Everything else stays manual and user-driven.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3, Vitest, Node.js ESM scripts, Tencent COS public HTTPS URLs
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the popup manifest URL to COS
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/update-config.ts`
|
||||
- Test: `tests/update-config.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing URL test**
|
||||
|
||||
Add a small test that asserts `UPDATE_MANIFEST_URL` points at the stable COS-hosted `latest.json` URL for this bucket:
|
||||
|
||||
```ts
|
||||
expect(UPDATE_MANIFEST_URL).toBe(
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-config.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the current constant still uses the placeholder example URL.
|
||||
|
||||
- [ ] **Step 3: Update the constant**
|
||||
|
||||
Replace the placeholder string in `src/shared/update-config.ts` with the COS `latest.json` URL above.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-config.test.ts tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Generate release assets from the COS base
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/write-latest-manifest.mjs`
|
||||
- Modify: `release/latest.json`
|
||||
- Test: `tests/update-check.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write a manifest generation regression test**
|
||||
|
||||
Add or extend a test that proves the generated manifest uses the COS release base for assets, not `example.com`. Use the COS base:
|
||||
|
||||
```ts
|
||||
https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2
|
||||
```
|
||||
|
||||
Assert that `zipUrl` and `guideUrl` are derived from that base.
|
||||
|
||||
- [ ] **Step 2: Run the focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-check.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL until the generator default points at COS.
|
||||
|
||||
- [ ] **Step 3: Update the generator default**
|
||||
|
||||
Change `publicBaseUrl` in `scripts/write-latest-manifest.mjs` to default to the COS release base for this bucket and region, while keeping `UPDATE_PUBLIC_BASE_URL` as the override path for future releases.
|
||||
|
||||
- [ ] **Step 4: Regenerate the tracked manifest**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run write:latest
|
||||
```
|
||||
|
||||
Then confirm `release/latest.json` contains the COS URLs.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-check.test.ts tests/popup-entry.test.ts tests/background-index.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Update distribution docs and verify COS access
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/internal-extension-distribution.md`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Update the release instructions**
|
||||
|
||||
Document the stable manifest URL, the versioned asset base, and the upload flow to COS. Keep the user-facing manual install steps unchanged.
|
||||
|
||||
- [ ] **Step 2: Add the COS verification command**
|
||||
|
||||
Document a `curl -I` check for the public `latest.json` URL and the uploaded ZIP/PDF so a failed COS ACL is caught before release.
|
||||
|
||||
- [ ] **Step 3: Run the final verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build:release
|
||||
```
|
||||
|
||||
Expected: PASS, and the generated release bundle should still open the popup update card correctly.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Popup Update Panel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Redesign the extension popup into a compact enterprise-style tool panel with clearer grouping, better spacing, and more readable update actions while keeping existing behavior unchanged.
|
||||
|
||||
**Architecture:** Keep the popup behavior in `src/popup/index.ts` and focus most UI changes in `src/popup/view.ts`. Add only the minimum structural changes needed to support grouped cards, clearer update states, and button hierarchy, then verify the existing popup tests still cover the update workflow.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 popup UI, Vitest, jsdom
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Replace the current long text flow with grouped cards and clearer status blocks.
|
||||
- Modify: `src/popup/index.html`
|
||||
- Add popup-level CSS for layout, spacing, typography, and button hierarchy.
|
||||
- Modify: `src/popup/index.ts`
|
||||
- Keep logic unchanged unless the new view structure needs small status-render support changes.
|
||||
- Modify: `tests/popup-entry.test.ts`
|
||||
- Update popup rendering expectations for the redesigned layout.
|
||||
|
||||
### Task 1: Lock the desired popup structure in tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/popup-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing layout assertions**
|
||||
|
||||
Update the logged-in and update-available popup tests to assert the new structure exists. Add checks for:
|
||||
|
||||
- a compact product header
|
||||
- an account card/root section
|
||||
- an update card/root section
|
||||
- a primary update button and secondary guide button
|
||||
|
||||
Use selectors that match the intended new DOM shape, for example:
|
||||
|
||||
```ts
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-update="card"]')
|
||||
).not.toBeNull();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused popup test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the current popup view does not expose the new grouped structure.
|
||||
|
||||
### Task 2: Redesign the logged-in popup shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `src/popup/index.html`
|
||||
|
||||
- [ ] **Step 1: Implement the grouped popup shell**
|
||||
|
||||
Change the logged-in renderer so it produces:
|
||||
|
||||
- a compact header
|
||||
- an account card
|
||||
- an update card container
|
||||
- a low-emphasis footer action area
|
||||
|
||||
Keep the same text content, just reorganized.
|
||||
|
||||
- [ ] **Step 2: Add popup CSS**
|
||||
|
||||
Add scoped CSS in `src/popup/index.html` for:
|
||||
|
||||
- wider popup body
|
||||
- neutral background
|
||||
- white cards
|
||||
- consistent spacing
|
||||
- smaller title scale
|
||||
- primary / secondary / tertiary button styles
|
||||
|
||||
Do not add animation-heavy or branding-heavy styles.
|
||||
|
||||
- [ ] **Step 3: Re-run popup tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS for structure-related checks, with any remaining failures isolated to update-state details.
|
||||
|
||||
### Task 3: Redesign update-state rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `src/popup/index.ts` (only if required)
|
||||
|
||||
- [ ] **Step 1: Write or update state-specific assertions**
|
||||
|
||||
Ensure tests cover:
|
||||
|
||||
- `checking` state shows a compact progress line
|
||||
- `latest` state hides download actions
|
||||
- `available` state shows current version, latest version, notes, and action buttons
|
||||
- `error` state renders a readable warning block
|
||||
|
||||
- [ ] **Step 2: Run the focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL on any states not yet aligned to the new view.
|
||||
|
||||
- [ ] **Step 3: Implement state-specific card rendering**
|
||||
|
||||
Refactor `renderUpdateStatus()` to keep one card shell and swap state bodies inside it. Make the available-update state visually prominent but restrained.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/popup-entry.test.ts tests/update-check.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Verify the popup still works end-to-end
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/view.ts` if polish is needed
|
||||
- Modify: `src/popup/index.html` if polish is needed
|
||||
|
||||
- [ ] **Step 1: Build the release popup**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build:release
|
||||
```
|
||||
|
||||
Expected: PASS and updated popup assets written to `dist/`.
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Manual smoke check**
|
||||
|
||||
Open the unpacked extension popup from `dist/` and confirm:
|
||||
|
||||
- title no longer wraps into a giant stacked block
|
||||
- account status is easy to scan
|
||||
- update information reads clearly
|
||||
- buttons are visually distinct and not cramped
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/popup/view.ts src/popup/index.html src/popup/index.ts tests/popup-entry.test.ts
|
||||
git commit -m "feat: redesign popup update panel"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Unified Dist Distribution Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make first-time install, Git-based install, ZIP-based install, and later updates all use the same unpacked extension directory named `dist/`.
|
||||
|
||||
**Architecture:** Standardize the extension output path through one shared build-path helper, then make ZIP packaging wrap that exact `dist/` directory, and finally update all teammate-facing docs to reference only that path. The release pipeline keeps its existing behavior, but the user-facing artifact shape becomes stable and singular.
|
||||
|
||||
**Tech Stack:** TypeScript, Node.js ESM scripts, Chrome MV3 extension packaging, Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `scripts/build-output-path.mjs`
|
||||
- Single source of truth for the unpacked extension output path.
|
||||
- Modify: `scripts/build.mjs`
|
||||
- Write release builds to `dist/`.
|
||||
- Modify: `scripts/package-release.mjs`
|
||||
- Package the shared `dist/` output instead of any alternate directory.
|
||||
- Modify: `scripts/package-release-archive.mjs`
|
||||
- Ensure ZIP output preserves a top-level `dist/` folder.
|
||||
- Modify: `tests/package-release-archive.test.ts`
|
||||
- Verify ZIP layout unpacks as `dist/...`.
|
||||
- Create: `tests/build-output-path.test.ts`
|
||||
- Verify the shared helper resolves `dist/`.
|
||||
- Modify: `playwright.config.js`
|
||||
- Load the extension from `dist/`.
|
||||
- Modify: `e2e-tests/extension-load.spec.js`
|
||||
- Verify files under `dist/`.
|
||||
- Modify: `README.md`
|
||||
- Remove `dist-release` references and describe `dist/` as the only unpacked directory.
|
||||
- Modify: `docs/internal-extension-distribution.md`
|
||||
- Align the release flow language with `dist/`.
|
||||
- Modify: `docs/【给同事】从Git下载使用说明.md`
|
||||
- Instruct coworkers to load `dist/`.
|
||||
- Modify: `.gitignore`
|
||||
- Remove obsolete `dist-release/` ignore entry if no longer needed.
|
||||
- Delete tracked directory: `dist-release/`
|
||||
- Remove the confusing second unpacked extension directory from the repo.
|
||||
|
||||
### Task 1: Lock the single output path in tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/build-output-path.test.ts`
|
||||
- Modify: `tests/package-release-archive.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing helper test**
|
||||
|
||||
Create a small test asserting both development and release builds resolve to:
|
||||
|
||||
```ts
|
||||
path.join("/repo", "dist")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing ZIP layout assertion**
|
||||
|
||||
Update the archive test so it expects:
|
||||
|
||||
```ts
|
||||
"dist/hello.txt"
|
||||
```
|
||||
|
||||
not a flat file list or any alternate top-level folder.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL until the shared path helper and ZIP layout are implemented.
|
||||
|
||||
### Task 2: Standardize build and package scripts
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/build-output-path.mjs`
|
||||
- Modify: `scripts/build.mjs`
|
||||
- Modify: `scripts/package-release.mjs`
|
||||
- Modify: `scripts/package-release-archive.mjs`
|
||||
|
||||
- [ ] **Step 1: Implement the shared output-path helper**
|
||||
|
||||
Add `resolveExtensionBuildDir(projectRoot, buildTarget)` and return `dist/` for both release and development flows.
|
||||
|
||||
- [ ] **Step 2: Update the build script**
|
||||
|
||||
Replace any hard-coded directory switching logic so the release build writes into `dist/`.
|
||||
|
||||
- [ ] **Step 3: Update the package script**
|
||||
|
||||
Point ZIP packaging at the same `dist/` directory.
|
||||
|
||||
- [ ] **Step 4: Keep ZIP layout stable**
|
||||
|
||||
Ensure the archive helper stores files under a top-level `dist/` directory inside the ZIP.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
|
||||
npm run build:release
|
||||
npm run package:internal
|
||||
```
|
||||
|
||||
Expected: PASS, and the ZIP should unpack as `dist/...`.
|
||||
|
||||
### Task 3: Remove `dist-release` from tooling and docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `playwright.config.js`
|
||||
- Modify: `e2e-tests/extension-load.spec.js`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/internal-extension-distribution.md`
|
||||
- Modify: `docs/【给同事】从Git下载使用说明.md`
|
||||
- Modify: `.gitignore`
|
||||
- Delete tracked directory: `dist-release/`
|
||||
|
||||
- [ ] **Step 1: Update tool references**
|
||||
|
||||
Point Playwright and debug paths at `dist/`.
|
||||
|
||||
- [ ] **Step 2: Update teammate docs**
|
||||
|
||||
Make every install/update instruction reference only `dist/`.
|
||||
|
||||
- [ ] **Step 3: Remove the obsolete tracked directory**
|
||||
|
||||
Delete the committed `dist-release/` tree so future contributors cannot mistake it for the live extension directory.
|
||||
|
||||
- [ ] **Step 4: Verify references**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "dist-release" README.md docs package.json scripts e2e-tests playwright.config.js src tests .gitignore
|
||||
```
|
||||
|
||||
Expected: no remaining user-facing `dist-release` references.
|
||||
|
||||
### Task 4: Final verification
|
||||
|
||||
**Files:**
|
||||
- Modify any of the above only if fixes are required
|
||||
|
||||
- [ ] **Step 1: Run targeted tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts tests/manifest.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Rebuild and inspect the artifact**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build:release
|
||||
npm run package:internal
|
||||
unzip -l release/star-chart-search-enhancer-internal.zip | sed -n '1,20p'
|
||||
```
|
||||
|
||||
Expected: the archive contents start with `dist/...`.
|
||||
|
||||
- [ ] **Step 3: Full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS, or if unrelated pre-existing failures remain, capture them explicitly before completion.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/build-output-path.mjs scripts/build.mjs scripts/package-release.mjs scripts/package-release-archive.mjs tests/build-output-path.test.ts tests/package-release-archive.test.ts README.md docs/internal-extension-distribution.md docs/【给同事】从Git下载使用说明.md playwright.config.js e2e-tests/extension-load.spec.js .gitignore
|
||||
git commit -m "chore: unify extension distribution around dist"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Market Audience Profile Export Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a separate CSV export for selected creators that includes the current market export fields plus audience profile data from each creator detail page's "连接用户" tab.
|
||||
|
||||
## User-Approved Decisions
|
||||
|
||||
- Add a new toolbar button named `导出画像CSV`.
|
||||
- Keep the existing `导出CSV` behavior unchanged.
|
||||
- Only allow the new export when at least one creator row is selected.
|
||||
- Do not support "export all" for profile data in this change because detail-page data costs extra API/page loads.
|
||||
- Suggested downloaded filename: `达人连接用户画像_YYYYMMDD_HHmm.csv`.
|
||||
- Export profile distributions as separate structured CSV columns, not as JSON blobs.
|
||||
|
||||
## Data Scope
|
||||
|
||||
Each exported row represents one selected creator. Start with the current market CSV columns, then append audience profile columns for:
|
||||
|
||||
- 性别分布
|
||||
- 年龄分布
|
||||
- 全国省份分布
|
||||
- 地域占比 TOP10
|
||||
- 城市等级分布
|
||||
- 兴趣分布
|
||||
- 八大人群占比
|
||||
|
||||
Fixed distributions should become fixed columns, for example:
|
||||
|
||||
- `连接用户-男性占比`
|
||||
- `连接用户-女性占比`
|
||||
- `连接用户-18-23占比`
|
||||
- `连接用户-24-30占比`
|
||||
- `省份-广东占比`
|
||||
- `城市等级-一线占比`
|
||||
- `八大人群-精致妈妈占比`
|
||||
|
||||
Ranked distributions should become name/value column pairs:
|
||||
|
||||
- `地域TOP1名称`
|
||||
- `地域TOP1占比`
|
||||
- ...
|
||||
- `地域TOP10名称`
|
||||
- `地域TOP10占比`
|
||||
- `兴趣TOP1名称`
|
||||
- `兴趣TOP1占比`
|
||||
- ...
|
||||
- `兴趣TOP10名称`
|
||||
- `兴趣TOP10占比`
|
||||
|
||||
Add a `画像抓取状态` column so partial failures are visible in CSV output.
|
||||
|
||||
## Data Acquisition
|
||||
|
||||
Use an on-demand detail-page probe. The implementation must first confirm the real data source from an authenticated creator detail page:
|
||||
|
||||
1. Prefer Xingtu `/gw/api/...` JSON responses if they expose the required profile data.
|
||||
2. If the API payload is difficult to locate or unstable, read the detail page's Vue/ECharts state from the page context.
|
||||
3. Avoid screen/OCR parsing and avoid relying on rendered chart pixels.
|
||||
|
||||
The export should process selected creators one at a time by default to respect API limits and reduce anti-abuse risk. Cache successful profile results in memory for the current page session.
|
||||
|
||||
## UX
|
||||
|
||||
Toolbar behavior:
|
||||
|
||||
- Add `导出画像CSV` next to the existing export actions.
|
||||
- Disable the button while any export/submission action is running.
|
||||
- If no creators are selected, show `请先勾选需要导出画像的达人`.
|
||||
- While exporting, show progress such as `画像导出中 3/12...`.
|
||||
- If plugin auth is expired, show `登录已过期,请重新登录`.
|
||||
- If one creator fails, keep the row in the CSV with `画像抓取状态=失败` and leave profile columns empty.
|
||||
- If all creators fail, do not download a CSV and show a failure message.
|
||||
|
||||
## Architecture
|
||||
|
||||
Add the feature as a separate export path instead of extending the existing `导出CSV` action. Reuse current selection state, market record hydration, CSV escaping, and runtime download path.
|
||||
|
||||
Proposed units:
|
||||
|
||||
- `audience-profile-client`: load and parse audience profile data for one creator detail page.
|
||||
- `audience-profile-csv`: combine existing market CSV columns with profile-specific columns.
|
||||
- Toolbar additions: add a button and handler for profile export.
|
||||
- Controller additions: filter to selected creators, call the profile client serially, build the CSV, then reuse `onCsvReady`.
|
||||
|
||||
## Testing
|
||||
|
||||
Use TDD. Add focused tests for:
|
||||
|
||||
- Auth state expired detection and user-facing expired-login text.
|
||||
- Toolbar renders and wires the new `导出画像CSV` button.
|
||||
- New export refuses to run without selected creators.
|
||||
- Profile CSV expands fixed and ranked distributions into separate columns.
|
||||
- Controller exports only selected creators and fetches profiles serially.
|
||||
- Failed creator profile fetches produce a failed row while successful rows still export.
|
||||
|
||||
Run focused tests first, then full `npm test`, then `npm run build`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Unselected/all-page profile export.
|
||||
- Persistent cross-session profile cache.
|
||||
- Visual dashboard UI for profile data.
|
||||
- Changing batch submission payloads.
|
||||
- OCR or screenshot-based chart extraction.
|
||||
@@ -0,0 +1,84 @@
|
||||
# COS Extension Update Design
|
||||
|
||||
## Goal
|
||||
|
||||
Use COS as the release source for extension updates. When the popup opens, it checks a public `latest.json` on COS. If the COS version is newer than the installed extension version, the popup shows an update card with download actions for the ZIP and the PDF guide, plus manual reload instructions.
|
||||
|
||||
## Confirmed Scope
|
||||
|
||||
- Update prompt appears only in the extension popup.
|
||||
- No star chart page banner in this change.
|
||||
- The user keeps the current manual install flow: download, unzip, replace the folder, then reload in `chrome://extensions`.
|
||||
|
||||
## Reusable Implementation
|
||||
|
||||
The current repo already has most of the flow:
|
||||
|
||||
- `src/shared/update-check.ts` parses the manifest and compares versions.
|
||||
- `src/popup/index.ts` checks for updates when the popup boots.
|
||||
- `src/popup/view.ts` renders the update status and download actions.
|
||||
- `src/background/index.ts` downloads the ZIP/PDF through `chrome.downloads`.
|
||||
- `scripts/write-latest-manifest.mjs` generates `release/latest.json`.
|
||||
|
||||
This change is mostly activation and configuration, not a rewrite.
|
||||
|
||||
## Manifest Contract
|
||||
|
||||
The public COS manifest must keep these fields:
|
||||
|
||||
- `latestVersion`
|
||||
- `minSupportedVersion`
|
||||
- `publishedAt`
|
||||
- `releaseNotes`
|
||||
- `zipUrl`
|
||||
- `guideUrl`
|
||||
|
||||
Rules:
|
||||
|
||||
- All asset URLs must be public HTTPS URLs.
|
||||
- Version comparison stays numeric dotted comparison.
|
||||
- Popup logic only needs `latestVersion` to decide whether to show the update card.
|
||||
- `minSupportedVersion` stays in the manifest for forward compatibility.
|
||||
|
||||
## COS Layout
|
||||
|
||||
Use a fixed release layout like:
|
||||
|
||||
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/latest.json`
|
||||
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
|
||||
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
|
||||
|
||||
## User Flow
|
||||
|
||||
1. User opens the popup.
|
||||
2. Popup reads the current extension version.
|
||||
3. Popup fetches COS `latest.json` with `no-store`.
|
||||
4. If `latestVersion` is not newer, show “当前已是最新版本”.
|
||||
5. If `latestVersion` is newer, show “发现新版本” plus release notes.
|
||||
6. User clicks:
|
||||
- `下载更新包` for the ZIP
|
||||
- `下载使用说明` for the PDF
|
||||
7. Popup shows the manual upgrade instructions after download starts.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the manifest is missing, invalid, or unreachable, the popup should show a non-blocking update error.
|
||||
- If download fails, the popup should show a download error and keep the plugin usable.
|
||||
- Update check failures must not block auth or normal plugin behavior.
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Build the release package.
|
||||
2. Package the internal ZIP.
|
||||
3. Generate `latest.json` with the real COS base URL.
|
||||
4. Upload `latest.json`, the ZIP, and the PDF to the COS folder.
|
||||
5. Replace the placeholder manifest URL in `src/shared/update-config.ts`.
|
||||
6. Rebuild and verify the popup update card.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Automatic in-place extension updates.
|
||||
- Auto-reload after download.
|
||||
- Star chart page update prompts.
|
||||
- Chrome Web Store publishing.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Popup Update Panel Design
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign the extension popup so it feels like a compact internal tool instead of a stretched document. The new popup should prioritize readability, clear grouping, and obvious update actions while keeping the interaction model unchanged.
|
||||
|
||||
## Confirmed Direction
|
||||
|
||||
- Visual style: enterprise tool
|
||||
- Primary problem to solve: information feels crowded and hard to read
|
||||
- Layout freedom: use whatever structure best improves clarity
|
||||
|
||||
## Scope
|
||||
|
||||
This change only redesigns the popup presentation layer:
|
||||
|
||||
- improve popup layout
|
||||
- improve spacing and typography
|
||||
- improve button hierarchy
|
||||
- improve update-status presentation
|
||||
|
||||
This change does not alter:
|
||||
|
||||
- auth flow
|
||||
- update-check logic
|
||||
- download behavior
|
||||
- background message behavior
|
||||
|
||||
## Layout
|
||||
|
||||
Use a grouped card layout instead of one long text column.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
1. Header
|
||||
- compact product title on one line
|
||||
2. Account card
|
||||
- login state
|
||||
- current user name
|
||||
3. Update card
|
||||
- current version
|
||||
- latest version or current status
|
||||
- release notes
|
||||
- primary and secondary action buttons
|
||||
- short follow-up instruction text
|
||||
4. Footer action
|
||||
- low-emphasis sign-out button
|
||||
|
||||
## Visual Rules
|
||||
|
||||
- Increase popup width to a more usable fixed tool width.
|
||||
- Use a soft neutral page background with white content cards.
|
||||
- Reduce title size significantly from the current oversized stacked text.
|
||||
- Use a clear type scale:
|
||||
- product title: medium emphasis
|
||||
- section title: medium emphasis
|
||||
- status/version rows: normal emphasis
|
||||
- helper text: smaller and lighter
|
||||
- Keep the palette restrained and neutral.
|
||||
- Make `下载更新包` the primary button.
|
||||
- Make `下载使用说明` a secondary button.
|
||||
- Make `退出登录` a tertiary or low-emphasis button.
|
||||
|
||||
## Update State Presentation
|
||||
|
||||
- `checking`: show a compact in-progress line inside the update card.
|
||||
- `latest`: show a simple success-style status without action buttons.
|
||||
- `available`: show a clear “发现新版本” status block with versions and actions.
|
||||
- `error`: show a readable warning block instead of generic broken-looking text flow.
|
||||
|
||||
## Content Rules
|
||||
|
||||
- Keep product name to one visual line or two short lines max.
|
||||
- Avoid large paragraphs in the popup.
|
||||
- Release notes should stay as a compact bullet list.
|
||||
- The post-download instruction should be one concise sentence.
|
||||
- Version labels should align consistently so the user can compare current/latest quickly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Keep the existing popup DOM entrypoints and rendering flow.
|
||||
- Focus changes in `src/popup/view.ts` first.
|
||||
- Only adjust popup controller code if needed to support cleaner status rendering.
|
||||
- Prefer CSS embedded in the popup HTML/view flow only as needed; do not expand scope into unrelated refactors.
|
||||
|
||||
## Testing
|
||||
|
||||
Add or update popup rendering tests for:
|
||||
|
||||
- logged-in layout still renders correctly
|
||||
- available update state still shows current/latest versions
|
||||
- update action buttons still exist
|
||||
- latest/error states still render expected text
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- new popup features
|
||||
- star chart page banners
|
||||
- animation-heavy UI
|
||||
- branding-heavy marketing visuals
|
||||
@@ -0,0 +1,122 @@
|
||||
# Unified Dist Distribution Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make first-time installation and later updates use the exact same extension directory structure so coworkers never need to learn different paths or loading rules.
|
||||
|
||||
## Confirmed Direction
|
||||
|
||||
- The only user-facing unpacked extension directory should be `dist/`.
|
||||
- First install and later update must produce the same directory name and shape.
|
||||
- Coworkers should always load the same directory in `chrome://extensions`.
|
||||
|
||||
## Problem
|
||||
|
||||
The current delivery flow has created multiple mental models:
|
||||
|
||||
- sometimes coworkers are told to load `dist-release/`
|
||||
- sometimes the ZIP extracts to a differently named top-level folder
|
||||
- sometimes Git-based installation and ZIP-based installation do not look identical
|
||||
|
||||
This is not user-friendly and increases support cost.
|
||||
|
||||
## Scope
|
||||
|
||||
This change standardizes the user-facing extension directory across:
|
||||
|
||||
- local release builds
|
||||
- ZIP packaging
|
||||
- Git-based teammate installation
|
||||
- later update downloads
|
||||
- installation and update documentation
|
||||
|
||||
This change does not alter:
|
||||
|
||||
- extension runtime behavior
|
||||
- update-check logic itself
|
||||
- popup auth flow
|
||||
- COS manifest format
|
||||
|
||||
## Distribution Rule
|
||||
|
||||
There must be exactly one user-facing extension directory:
|
||||
|
||||
- `dist/`
|
||||
|
||||
This rule must hold in all paths:
|
||||
|
||||
1. Git install
|
||||
- teammate runs the release build
|
||||
- resulting unpacked extension directory is `dist/`
|
||||
2. ZIP install
|
||||
- teammate unzips the package
|
||||
- resulting top-level extension directory is `dist/`
|
||||
3. ZIP update
|
||||
- teammate downloads the newer ZIP
|
||||
- unzip result is also `dist/`
|
||||
- teammate replaces the previous `dist/`
|
||||
|
||||
## Packaging Rule
|
||||
|
||||
The release ZIP should unpack into a single top-level folder named `dist/`.
|
||||
|
||||
Expected unpack result:
|
||||
|
||||
- `dist/manifest.json`
|
||||
- `dist/background/`
|
||||
- `dist/content/`
|
||||
- `dist/popup/`
|
||||
- `dist/assets/`
|
||||
|
||||
The ZIP must not unpack as:
|
||||
|
||||
- a flat file list
|
||||
- `dist-release/`
|
||||
- `star-chart-search-enhancer-internal/`
|
||||
- any other user-facing top-level folder name
|
||||
|
||||
## Build Rule
|
||||
|
||||
Release builds should write the unpacked extension directly to `dist/`.
|
||||
|
||||
There should not be a second extension build directory that teammates might mistake as the correct one.
|
||||
|
||||
If internal scripts need a concept of “release build,” that should be represented by:
|
||||
|
||||
- environment/config
|
||||
- release manifest values
|
||||
- packaging flow
|
||||
|
||||
but not by exposing a second unpacked directory name to users.
|
||||
|
||||
## Documentation Rule
|
||||
|
||||
All teammate-facing docs must say the same thing:
|
||||
|
||||
- first install: load `dist/`
|
||||
- later update: replace `dist/` and reload
|
||||
|
||||
No user-facing doc should mention `dist-release/`.
|
||||
|
||||
## CI / Release Rule
|
||||
|
||||
Drone and local release scripts must publish only one ZIP layout:
|
||||
|
||||
- unzip result is `dist/`
|
||||
|
||||
The release flow must not generate a user ZIP whose folder layout differs from the Git-based install path.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `npm run build:release` writes the unpacked extension to `dist/`
|
||||
- release ZIP unpacks into a top-level `dist/` folder
|
||||
- first install and later update ZIPs unpack to the same structure
|
||||
- coworker docs consistently reference `dist/`
|
||||
- repository no longer contains a second tracked unpacked extension directory that conflicts with `dist/`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- changing extension features
|
||||
- changing the update notification UX
|
||||
- switching away from unpacked-extension installation
|
||||
- Chrome Web Store or enterprise force-install deployment
|
||||
@@ -0,0 +1,134 @@
|
||||
# 📥 从 Git 下载使用插件
|
||||
|
||||
> 适合:第一次从 Git 仓库下载的同事
|
||||
|
||||
---
|
||||
|
||||
## 第一步:安装 Git(如果还没装)
|
||||
|
||||
**Mac 用户**:
|
||||
打开终端,输入:
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
如果没安装,系统会提示你安装。
|
||||
|
||||
**Windows 用户**:
|
||||
1. 访问 https://git-scm.com/download/win
|
||||
2. 下载并安装(一直点"下一步"即可)
|
||||
|
||||
---
|
||||
|
||||
## 第二步:下载插件代码
|
||||
|
||||
打开终端(Mac)或命令提示符/PowerShell(Windows),输入:
|
||||
|
||||
```bash
|
||||
cd Desktop
|
||||
git clone https://git.internal.intelligrow.cn/wangshaoqing/star-chart-search-enhancer.git
|
||||
```
|
||||
|
||||
等待下载完成,桌面上会出现一个文件夹 `star-chart-search-enhancer`。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:安装插件到 Chrome
|
||||
|
||||
1. 打开 Chrome 浏览器
|
||||
2. 地址栏输入 `chrome://extensions` 并回车
|
||||
3. 右上角打开 **"开发者模式"**
|
||||
4. 点击 **"加载已解压的扩展程序"**
|
||||
5. 选择桌面上的这个路径:
|
||||
```
|
||||
star-chart-search-enhancer/dist/
|
||||
```
|
||||
|
||||
⚠️ **重要**:必须选择 `dist` 这个子文件夹,不要选外层文件夹
|
||||
|
||||
✅ 安装成功!你会看到插件卡片。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:确认扩展 ID
|
||||
|
||||
点击插件卡片的 **"详情"**,确认 ID 是:
|
||||
|
||||
**`pkjopdibdnomhogjheclhnknmejccffg`**
|
||||
|
||||
如果这个 ID 不对,说明下载有问题,请联系我。
|
||||
|
||||
---
|
||||
|
||||
## 第五步:登录并开始使用
|
||||
|
||||
1. 点击 Chrome 右上角的拼图图标 🧩
|
||||
2. 找到 **Star Chart Search Enhancer**,点击图钉 📌 固定
|
||||
3. 点击插件图标 → **登录**
|
||||
4. 完成公司账号登录
|
||||
5. 打开星图页面开始使用
|
||||
|
||||
---
|
||||
|
||||
## 🆕 最近修复
|
||||
|
||||
当前版本已修复:
|
||||
|
||||
- 在星图搜索关键词后,左侧插件勾选框和右侧达人行可能逐行错位的问题
|
||||
|
||||
如果你之前已经装过旧版本,请务必按下面的“后续更新”步骤执行一次更新和重新加载。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 后续更新
|
||||
|
||||
当我发布新版本时,你只需要:
|
||||
|
||||
```bash
|
||||
cd Desktop/star-chart-search-enhancer
|
||||
git pull
|
||||
npm run build:release
|
||||
```
|
||||
|
||||
然后到 `chrome://extensions` 页面点击插件卡片的 **"重新加载"** 🔄
|
||||
|
||||
推荐完整更新顺序:
|
||||
|
||||
1. 先关闭星图页面标签页
|
||||
2. 执行 `git pull`
|
||||
3. 执行 `npm run build:release`
|
||||
4. 到 `chrome://extensions` 点击插件卡片的 **"重新加载"**
|
||||
5. 重新打开星图页面
|
||||
|
||||
如果你主要使用的是“达人市场”页面,更新后可以这样快速确认修复是否生效:
|
||||
|
||||
1. 打开 `https://xingtu.cn/ad/creator/market`
|
||||
2. 在搜索框输入关键词,例如 `孕晚期`
|
||||
3. 向下滚动到达人列表区域
|
||||
4. 确认左侧勾选框和右侧达人行保持对齐
|
||||
|
||||
⚠️ **如果重新加载后还是旧版本**:
|
||||
- 先点击插件卡片的 **"移除"** 删除旧版本
|
||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||
- 再次选择 `dist` 文件夹
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
**Q: 提示 "git 不是内部或外部命令"?**
|
||||
A: Git 没装好,请先安装 Git。
|
||||
|
||||
**Q: 下载后找不到 dist 文件夹?**
|
||||
A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新执行 `npm run build:release`。
|
||||
|
||||
**Q: 加载后扩展 ID 不对?**
|
||||
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果以上步骤遇到问题,请截图发给我:
|
||||
1. 你在哪一步卡住了
|
||||
2. 完整的错误提示
|
||||
3. Chrome 扩展页面的截图
|
||||
@@ -0,0 +1,177 @@
|
||||
# 🌟 星图增强插件 - 超简单使用指南
|
||||
|
||||
> 适合:完全没用过插件的新手 | 阅读时间:3分钟
|
||||
|
||||
---
|
||||
|
||||
## 📦 第一步:拿到压缩包
|
||||
|
||||
你会从同事那里收到一个文件:
|
||||
|
||||
**`star-chart-search-enhancer-internal.zip`**
|
||||
|
||||
把它保存在桌面上,不要删掉。
|
||||
|
||||
---
|
||||
|
||||
## 📂 第二步:解压(右键就行)
|
||||
|
||||
1. 在桌面上找到这个压缩包
|
||||
2. **右键** → 选择"解压到当前文件夹"(或"Extract Here")
|
||||
3. 会多出一个文件夹,名字是 `dist`
|
||||
|
||||
⚠️ **重要**:这个 `dist` 文件夹要一直放在桌面,不要删、不要改名
|
||||
|
||||
---
|
||||
|
||||
## 🔧 第三步:安装到 Chrome(只需做一次)
|
||||
|
||||
1. 打开 **Chrome 浏览器**
|
||||
2. 在地址栏输入:
|
||||
```
|
||||
chrome://extensions
|
||||
```
|
||||
然后按回车
|
||||
|
||||
3. 右上角找到 **"开发者模式"** → 打开开关(点一下变蓝色)
|
||||
|
||||
4. 点击左上角出现的 **"加载已解压的扩展程序"**
|
||||
|
||||
5. 选择刚才解压出来的 `dist` 文件夹
|
||||
|
||||
⚠️ **重要**:如果 `dist` 文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
|
||||
|
||||
6. 看到绿色的插件卡片出现,就装好了!
|
||||
|
||||
✅ **检查**:点击"详情",确认 ID 是 `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
---
|
||||
|
||||
## 🔑 第四步:登录(只用登录一次)
|
||||
|
||||
1. 点击 Chrome 右上角的 **拼图图标** 🧩
|
||||
2. 找到 **Star Chart Search Enhancer**
|
||||
3. 点击 **图钉** 📌 把它固定到工具栏
|
||||
4. 点击插件图标,然后点 **"登录"**
|
||||
5. 按提示完成公司账号登录
|
||||
|
||||
---
|
||||
|
||||
## 🚀 第五步:开始使用
|
||||
|
||||
### 打开星图页面
|
||||
|
||||
访问:
|
||||
```
|
||||
https://xingtu.cn/ad/creator/market
|
||||
```
|
||||
|
||||
等待页面加载,你会看到页面上多了一排新按钮。
|
||||
|
||||
---
|
||||
|
||||
## 📝 主要功能
|
||||
|
||||
### 1️⃣ 导出达人数据(CSV)
|
||||
|
||||
当你需要导出达人的内容数据、效果预估、画像时使用:
|
||||
|
||||
- 先勾选你想导出数据的达人
|
||||
- 点击 **"导出选中达人数据"**
|
||||
- 等待下载完成
|
||||
- 文件自动下载到电脑的"下载"文件夹
|
||||
|
||||
⚠️ **重要**:导出达人数据必须先勾选达人,因为它会额外请求达人详情页数据,不能默认导出全部。
|
||||
|
||||
- 内容数据:个人视频/星图视频的播放量中位数、完播率、互动率、发布作品、平均时长、平均点赞、平均评论、平均转发
|
||||
- 效果预估:不同视频时长的预期CPM、预期CPE、预期播放量、爆文率
|
||||
- 观众画像、粉丝画像、铁粉画像
|
||||
- 秒思api数据:看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search
|
||||
|
||||
**只导出部分字段**:
|
||||
- 点击 **"选择字段"**
|
||||
- 勾选你需要的字段,取消不需要的字段
|
||||
- 点击 **"保存"**
|
||||
- 再点击 **"导出选中达人数据"** 或 **"按星图ID导出"**
|
||||
|
||||
说明:达人ID、达人名称、导出状态、失败原因等基础字段会固定保留;你保存过一次后,下次导出会自动沿用这次勾选结果,不需要重新勾选。
|
||||
|
||||
### 2️⃣ 按ID导出达人数据
|
||||
|
||||
当你想批量查询特定达人ID的数据时使用:
|
||||
|
||||
- 点击 **"按星图ID导出"**
|
||||
- 在弹出的对话框中输入达人ID(每行一个)
|
||||
- 点击确认
|
||||
- 等待下载完成
|
||||
|
||||
**适用场景**:已知一批达人星图ID,需要批量导出这些达人的CSV。
|
||||
|
||||
### 3️⃣ 提交批次
|
||||
|
||||
- 勾选你想提交的达人
|
||||
- 点击 **"提交批次"**
|
||||
- 输入批次名称(例如:`5月母婴达人第一批`)
|
||||
- 点击确认
|
||||
|
||||
### 4️⃣ 更新插件
|
||||
|
||||
- 点击浏览器右上角的插件图标
|
||||
- 在 **"版本更新"** 区域查看是否有新版本
|
||||
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
|
||||
- 解压下载到的新版本 zip
|
||||
- 打开 `chrome://extensions`
|
||||
- 找到 `Star Chart Search Enhancer`
|
||||
- 点击 **"重新加载"**,或重新选择解压后的 `dist` 文件夹
|
||||
|
||||
---
|
||||
|
||||
## 🔄 如何更新插件
|
||||
|
||||
收到新版本压缩包时:
|
||||
|
||||
1. 删掉桌面上的旧文件夹
|
||||
2. 解压新的压缩包
|
||||
3. 打开 `chrome://extensions`
|
||||
4. 找到插件卡片,点击 **"重新加载"** 🔄
|
||||
|
||||
⚠️ **如果重新加载后还是旧版本**:
|
||||
- 先点击插件卡片的 **"移除"** 删除旧版本
|
||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||
- 再次选择新解压出来的 `dist` 文件夹
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 页面没有多出按钮?
|
||||
A: 先点击插件图标确认已登录,然后刷新页面(按 F5)
|
||||
|
||||
### Q: 提示登录失败?
|
||||
A: 关闭弹窗再试一次,或检查网络连接
|
||||
|
||||
### Q: 导出没反应?
|
||||
A: 检查浏览器的下载列表,文件可能已经下好了
|
||||
|
||||
### Q: 不小心把文件夹删了?
|
||||
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
||||
|
||||
---
|
||||
|
||||
## ✅ 每日使用 checklist
|
||||
|
||||
- [ ] 打开 Chrome,确认插件图标在
|
||||
- [ ] 点击图标,确认显示"已登录"
|
||||
- [ ] 打开星图页面
|
||||
- [ ] 正常使用导出/提交功能
|
||||
|
||||
---
|
||||
|
||||
## 🆘 还是不行?
|
||||
|
||||
把下面信息发给同事:
|
||||
1. 你在哪一步卡住了
|
||||
2. 页面截图
|
||||
3. 扩展 ID(从 chrome://extensions 里看)
|
||||
|
||||
**记住正确的 ID:`pkjopdibdnomhogjheclhnknmejccffg`**
|
||||
Generated
+955
-1
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -5,15 +5,23 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
|
||||
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
||||
"release:tag": "node scripts/ci/release-tag.mjs",
|
||||
"package:internal": "npm run build:release && node scripts/package-release.mjs",
|
||||
"package:release": "npm run build:release && node scripts/package-release.mjs",
|
||||
"write:latest": "node scripts/write-latest-manifest.mjs",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/chrome-extension": "^0.1.27"
|
||||
"@logto/chrome-extension": "^0.1.27",
|
||||
"yazl": "^3.3.1"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||
"jsdom": "^29.0.2",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"guideUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/星图增强插件-超简单安装使用指南.pdf",
|
||||
"latestVersion": "0.0525.6",
|
||||
"minSupportedVersion": "0.0525.6",
|
||||
"publishedAt": "2026-05-25",
|
||||
"releaseNotes": [
|
||||
"支持在插件弹窗中检查新版本",
|
||||
"支持一键下载最新版插件压缩包和使用说明"
|
||||
],
|
||||
"zipUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/star-chart-search-enhancer-internal.zip"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveExtensionBuildDir(projectRoot, _buildTarget) {
|
||||
return path.join(projectRoot, "dist");
|
||||
}
|
||||
+13
-5
@@ -1,12 +1,15 @@
|
||||
import { cp, mkdir, rm } from "node:fs/promises";
|
||||
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "tsup";
|
||||
import { createManifest } from "./manifest.mjs";
|
||||
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const distDir = path.join(projectRoot, "dist");
|
||||
const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
|
||||
const distDir = resolveExtensionBuildDir(projectRoot, buildTarget);
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
await mkdir(path.join(distDir, "content"), { recursive: true });
|
||||
@@ -68,11 +71,16 @@ await build({
|
||||
}
|
||||
});
|
||||
|
||||
await cp(
|
||||
path.join(projectRoot, "src/manifest.json"),
|
||||
path.join(distDir, "manifest.json")
|
||||
await writeFile(
|
||||
path.join(distDir, "manifest.json"),
|
||||
`${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n`
|
||||
);
|
||||
await cp(
|
||||
path.join(projectRoot, "src/popup/index.html"),
|
||||
path.join(distDir, "popup/index.html")
|
||||
);
|
||||
await cp(
|
||||
path.join(projectRoot, "src/assets"),
|
||||
path.join(distDir, "assets"),
|
||||
{ recursive: true }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { resolveReleaseVersion } from "../release-version.mjs";
|
||||
import { uploadReleaseAssets } from "./upload-release-assets.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function runReleaseTagPipeline(env = process.env) {
|
||||
const projectRoot = resolveProjectRoot();
|
||||
const releaseVersion = resolveReleaseVersion(env);
|
||||
const publicBaseUrl =
|
||||
env.UPDATE_PUBLIC_BASE_URL ??
|
||||
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${releaseVersion}`;
|
||||
|
||||
console.log(`release version: ${releaseVersion}`);
|
||||
console.log("running build:release");
|
||||
await runNpmScript("build:release", projectRoot, {
|
||||
...env,
|
||||
EXTENSION_VERSION: releaseVersion
|
||||
});
|
||||
|
||||
console.log("running package-release");
|
||||
await runNodeScript("scripts/package-release.mjs", projectRoot, {
|
||||
...env,
|
||||
EXTENSION_VERSION: releaseVersion
|
||||
});
|
||||
|
||||
console.log("writing latest manifest");
|
||||
await runNpmScript("write:latest", projectRoot, {
|
||||
...env,
|
||||
EXTENSION_VERSION: releaseVersion,
|
||||
UPDATE_PUBLIC_BASE_URL: publicBaseUrl
|
||||
});
|
||||
|
||||
console.log("uploading release assets to COS");
|
||||
await uploadReleaseAssets({
|
||||
env: {
|
||||
...env,
|
||||
EXTENSION_VERSION: releaseVersion
|
||||
},
|
||||
projectRoot,
|
||||
releaseVersion
|
||||
});
|
||||
}
|
||||
|
||||
async function runNpmScript(scriptName, cwd, env) {
|
||||
await execFileAsync("npm", ["run", scriptName], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
}
|
||||
|
||||
async function runNodeScript(scriptPath, cwd, env) {
|
||||
await execFileAsync("node", [scriptPath], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
}
|
||||
|
||||
function resolveProjectRoot() {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await runReleaseTagPipeline();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import COS from "cos-nodejs-sdk-v5";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { buildReleaseUploadTargets } from "../release-assets.mjs";
|
||||
|
||||
export async function uploadReleaseAssets(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const projectRoot = options.projectRoot ?? resolveProjectRoot();
|
||||
const releaseVersion = options.releaseVersion ?? env.EXTENSION_VERSION ?? env.DRONE_TAG;
|
||||
|
||||
if (!releaseVersion) {
|
||||
throw new Error("release version is required for COS upload");
|
||||
}
|
||||
|
||||
const cos = options.cosClient ?? createCosClient(env);
|
||||
const targets =
|
||||
options.targets ??
|
||||
buildReleaseUploadTargets({
|
||||
projectRoot,
|
||||
releaseVersion
|
||||
});
|
||||
|
||||
for (const target of targets) {
|
||||
const body = await readFile(target.localPath);
|
||||
await putObjectAsync(cos, {
|
||||
Bucket: getRequiredEnv(env, "COS_BUCKET"),
|
||||
Body: body,
|
||||
ContentType: getContentType(target.cosKey),
|
||||
Key: target.cosKey,
|
||||
Region: getRequiredEnv(env, "COS_REGION")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function putObjectAsync(client, params) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
client.putObject(params, (error, data) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createCosClient(env) {
|
||||
return new COS({
|
||||
SecretId: getRequiredEnv(env, "COS_SECRET_ID"),
|
||||
SecretKey: getRequiredEnv(env, "COS_SECRET_KEY")
|
||||
});
|
||||
}
|
||||
|
||||
function getContentType(key) {
|
||||
if (key.endsWith(".json")) {
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (key.endsWith(".pdf")) {
|
||||
return "application/pdf";
|
||||
}
|
||||
|
||||
if (key.endsWith(".zip")) {
|
||||
return "application/zip";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function getRequiredEnv(env, name) {
|
||||
const value = env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveProjectRoot() {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { resolveReleaseVersion } from "./release-version.mjs";
|
||||
|
||||
const sharedIcons = {
|
||||
16: "assets/icons/icon-16.png",
|
||||
32: "assets/icons/icon-32.png",
|
||||
48: "assets/icons/icon-48.png",
|
||||
128: "assets/icons/icon-128.png"
|
||||
};
|
||||
const extensionKey =
|
||||
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB";
|
||||
|
||||
const sharedManifest = {
|
||||
action: {
|
||||
default_icon: {
|
||||
16: sharedIcons[16],
|
||||
32: sharedIcons[32]
|
||||
},
|
||||
default_popup: "popup/index.html"
|
||||
},
|
||||
background: {
|
||||
service_worker: "background/index.js"
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
js: ["content/index.js"],
|
||||
matches: [
|
||||
"https://xingtu.cn/ad/creator/market*",
|
||||
"https://*.xingtu.cn/ad/creator/market*"
|
||||
],
|
||||
run_at: "document_start"
|
||||
}
|
||||
],
|
||||
description: "Bootstraps the Xingtu creator market content script.",
|
||||
icons: sharedIcons,
|
||||
key: extensionKey,
|
||||
manifest_version: 3,
|
||||
name: "Star Chart Search Enhancer",
|
||||
permissions: ["downloads", "identity", "storage"],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
matches: [
|
||||
"https://xingtu.cn/*",
|
||||
"https://*.xingtu.cn/*"
|
||||
],
|
||||
resources: ["content/market-page-bridge.js"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const hostPermissionsByTarget = {
|
||||
development: [
|
||||
"http://*/*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"http://127.0.0.1:4319/*",
|
||||
"https://*/*"
|
||||
],
|
||||
release: [
|
||||
"https://xingtu.cn/ad/creator/market*",
|
||||
"https://*.xingtu.cn/ad/creator/market*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"https://talent-search.intelligrow.cn/*",
|
||||
"http://192.168.31.21:8083/*",
|
||||
"https://*/*"
|
||||
]
|
||||
};
|
||||
|
||||
export function createManifest(options = {}) {
|
||||
const target = options.target ?? "development";
|
||||
const hostPermissions = hostPermissionsByTarget[target];
|
||||
if (!hostPermissions) {
|
||||
throw new Error(`Unsupported manifest target: ${target}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...sharedManifest,
|
||||
version: resolveReleaseVersion(),
|
||||
host_permissions: hostPermissions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import yazl from "yazl";
|
||||
|
||||
export async function createReleaseArchive({
|
||||
archivePath,
|
||||
rootDirName = "dist",
|
||||
sourceDir
|
||||
}) {
|
||||
const zip = new yazl.ZipFile();
|
||||
const output = createWriteStream(archivePath);
|
||||
|
||||
await addDirectory(zip, sourceDir, sourceDir, rootDirName);
|
||||
zip.end();
|
||||
await pipeline(zip.outputStream, output);
|
||||
}
|
||||
|
||||
async function addDirectory(zip, rootDir, currentDir, rootDirName) {
|
||||
const entries = await readdir(currentDir, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
const relativePath = path.relative(rootDir, absolutePath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await addDirectory(zip, rootDir, absolutePath, rootDirName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
zip.addFile(absolutePath, path.join(rootDirName, relativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createReleaseArchive } from "./package-release-archive.mjs";
|
||||
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const sourceDir = resolveExtensionBuildDir(projectRoot, "release");
|
||||
const releaseDir = path.join(projectRoot, "release");
|
||||
const archivePath = path.join(
|
||||
releaseDir,
|
||||
"star-chart-search-enhancer-internal.zip"
|
||||
);
|
||||
|
||||
await mkdir(releaseDir, { recursive: true });
|
||||
await rm(archivePath, { force: true });
|
||||
await createReleaseArchive({
|
||||
archivePath,
|
||||
sourceDir
|
||||
});
|
||||
|
||||
console.log(`Internal archive created at ${archivePath}`);
|
||||
@@ -0,0 +1,25 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function buildReleaseUploadTargets({
|
||||
projectRoot,
|
||||
releaseVersion
|
||||
}) {
|
||||
const releaseDir = path.join(projectRoot, "release");
|
||||
const releasePrefix = "star-chart-search-enhancer";
|
||||
const releaseVersionPrefix = `${releasePrefix}/releases/${releaseVersion}`;
|
||||
|
||||
return [
|
||||
{
|
||||
cosKey: `${releasePrefix}/latest.json`,
|
||||
localPath: path.join(releaseDir, "latest.json")
|
||||
},
|
||||
{
|
||||
cosKey: `${releaseVersionPrefix}/star-chart-search-enhancer-internal.zip`,
|
||||
localPath: path.join(releaseDir, "star-chart-search-enhancer-internal.zip")
|
||||
},
|
||||
{
|
||||
cosKey: `${releaseVersionPrefix}/星图增强插件-超简单安装使用指南.pdf`,
|
||||
localPath: path.join(releaseDir, "星图增强插件-超简单安装使用指南.pdf")
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const RELEASE_VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
|
||||
|
||||
export function normalizeReleaseVersionTag(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
if (!RELEASE_VERSION_PATTERN.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveReleaseVersion(
|
||||
env = process.env,
|
||||
fallbackVersion = "0.2.0421.2"
|
||||
) {
|
||||
const candidates = [env.EXTENSION_VERSION, env.DRONE_TAG, fallbackVersion];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeReleaseVersionTag(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("unable to resolve a valid release version");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function createLatestManifest(options) {
|
||||
const publishedAt = options.publishedAt ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
guideUrl: `${options.publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`,
|
||||
latestVersion: options.latestVersion,
|
||||
minSupportedVersion: options.minSupportedVersion,
|
||||
publishedAt,
|
||||
releaseNotes: [
|
||||
"支持在插件弹窗中检查新版本",
|
||||
"支持一键下载最新版插件压缩包和使用说明"
|
||||
],
|
||||
zipUrl: `${options.publicBaseUrl}/star-chart-search-enhancer-internal.zip`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createManifest } from "./manifest.mjs";
|
||||
import { createLatestManifest } from "./write-latest-manifest-data.mjs";
|
||||
import { resolveReleaseVersion } from "./release-version.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const releaseDir = path.join(projectRoot, "release");
|
||||
const releaseManifest = createManifest({ target: "release" });
|
||||
const latestVersion =
|
||||
process.env.LATEST_VERSION ?? resolveReleaseVersion(process.env, releaseManifest.version);
|
||||
const publicBaseUrl =
|
||||
process.env.UPDATE_PUBLIC_BASE_URL ??
|
||||
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${latestVersion}`;
|
||||
const latestManifest = createLatestManifest({
|
||||
latestVersion,
|
||||
minSupportedVersion: releaseManifest.version,
|
||||
publicBaseUrl
|
||||
});
|
||||
|
||||
await mkdir(releaseDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(releaseDir, "latest.json"),
|
||||
`${JSON.stringify(latestManifest, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 777 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="12" x2="114" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9F1239"/>
|
||||
<stop offset="1" stop-color="#4C0519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
|
||||
<path d="M34 80L50 62L64 70L83 46" stroke="#FFF7ED" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
||||
<circle cx="86" cy="44" r="8" fill="#FFF7ED"/>
|
||||
<path d="M79 80C79 71.7157 85.7157 65 94 65C102.284 65 109 71.7157 109 80C109 88.2843 102.284 95 94 95C85.7157 95 79 88.2843 79 80Z" stroke="#FFF7ED" stroke-width="8"/>
|
||||
<path d="M104 91L114 101" stroke="#FFF7ED" stroke-linecap="round" stroke-width="8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 822 B |
@@ -26,6 +26,15 @@ export function createAuthController(options: {
|
||||
return createLoggedOutAuthState(config);
|
||||
}
|
||||
|
||||
try {
|
||||
await options.authClient.getAccessToken(config.apiResource);
|
||||
} catch (error) {
|
||||
return createLoggedOutAuthState(
|
||||
config,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const claims = await options.authClient.getIdTokenClaims();
|
||||
return createLoggedInAuthState(claims, config);
|
||||
},
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { AuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
|
||||
export function createLoggedOutAuthState(
|
||||
config?: Pick<AuthConfig, "apiResource">
|
||||
config?: Pick<AuthConfig, "apiResource">,
|
||||
lastError?: string | null
|
||||
): AuthStateValue {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
lastError: lastError ?? null,
|
||||
resource: config?.apiResource ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ type BatchSubmitMessage = {
|
||||
type: "batch:submit";
|
||||
};
|
||||
|
||||
type DownloadUpdateMessage = {
|
||||
filename: string;
|
||||
type: "update:download";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function registerBackgroundMessageHandler(
|
||||
chromeLike: ChromeLike = readChromeLike(),
|
||||
dependencies: {
|
||||
@@ -77,6 +83,22 @@ export function registerBackgroundMessageHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isDownloadUpdateMessage(message)) {
|
||||
void triggerUpdateDownload(chromeLike, message)
|
||||
.then(() => {
|
||||
sendResponse({ ok: true, type: "update:download-ack" });
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "update:download-error"
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBatchSubmitMessage(message)) {
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
@@ -161,6 +183,23 @@ export function registerBackgroundMessageHandler(
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerUpdateDownload(
|
||||
chromeLike: ChromeLike,
|
||||
message: DownloadUpdateMessage
|
||||
): Promise<void> {
|
||||
if (!chromeLike.downloads?.download) {
|
||||
throw new Error("chrome.downloads.download is unavailable");
|
||||
}
|
||||
|
||||
await Promise.resolve(
|
||||
chromeLike.downloads.download({
|
||||
filename: message.filename,
|
||||
saveAs: true,
|
||||
url: message.url
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAuthMessage(
|
||||
authController: AuthController,
|
||||
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
||||
@@ -239,6 +278,22 @@ function isDownloadMarketCsvMessage(
|
||||
);
|
||||
}
|
||||
|
||||
function isDownloadUpdateMessage(
|
||||
message: unknown
|
||||
): message is DownloadUpdateMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = message as Partial<DownloadUpdateMessage>;
|
||||
return (
|
||||
candidate.type === "update:download" &&
|
||||
typeof candidate.filename === "string" &&
|
||||
typeof candidate.url === "string" &&
|
||||
candidate.url.startsWith("https://")
|
||||
);
|
||||
}
|
||||
|
||||
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
|
||||
+51
-8
@@ -39,24 +39,36 @@ export async function bootContentScript(
|
||||
return null;
|
||||
}
|
||||
|
||||
installMarketPageBridge(currentDocument);
|
||||
|
||||
const authState = await readAuthState(sendAuthMessage);
|
||||
if (!authState?.isAuthenticated) {
|
||||
renderMarketAuthGate(currentDocument, currentWindow);
|
||||
await waitForBodyReady(currentDocument, currentWindow);
|
||||
renderMarketAuthGate(
|
||||
currentDocument,
|
||||
currentWindow,
|
||||
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
|
||||
);
|
||||
return {
|
||||
ready: Promise.resolve()
|
||||
};
|
||||
}
|
||||
|
||||
installMarketPageBridge(currentDocument);
|
||||
await waitForBodyReady(currentDocument, currentWindow);
|
||||
|
||||
return controllerFactory({
|
||||
document: currentDocument,
|
||||
onCsvReady: (csv: string) => {
|
||||
onCsvReady: (csv: string, filename?: string) => {
|
||||
if (filename) {
|
||||
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCsvDownload(csv)) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv(currentDocument, currentWindow, csv);
|
||||
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||
},
|
||||
window: currentWindow
|
||||
});
|
||||
@@ -109,7 +121,7 @@ function bootstrapContentScript() {
|
||||
|
||||
bootstrapContentScript();
|
||||
|
||||
function requestCsvDownload(csv: string): boolean {
|
||||
function requestCsvDownload(csv: string, filename?: string): boolean {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: { runtime?: ChromeRuntimeLike };
|
||||
@@ -122,7 +134,7 @@ function requestCsvDownload(csv: string): boolean {
|
||||
|
||||
runtime.sendMessage({
|
||||
csv,
|
||||
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||
filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||
});
|
||||
return true;
|
||||
@@ -144,14 +156,37 @@ function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function downloadCsv(document: Document, window: Window, csv: string): void {
|
||||
async function waitForBodyReady(document: Document, currentWindow: Window): Promise<void> {
|
||||
if (document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handleReady = () => {
|
||||
if (document.body) {
|
||||
document.removeEventListener("DOMContentLoaded", handleReady);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", handleReady);
|
||||
currentWindow.setTimeout(handleReady, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadCsv(
|
||||
document: Document,
|
||||
window: Window,
|
||||
csv: string,
|
||||
filename?: string
|
||||
): void {
|
||||
const blob = new Blob(["\uFEFF", csv], {
|
||||
type: "text/csv;charset=utf-8"
|
||||
});
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||
link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -162,6 +197,14 @@ function formatTimestampForFilename(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function isExpiredAuthState(authState: AuthStateValue | null): boolean {
|
||||
const lastError = authState?.lastError;
|
||||
return (
|
||||
typeof lastError === "string" &&
|
||||
(/token/i.test(lastError) || lastError.includes("过期"))
|
||||
);
|
||||
}
|
||||
|
||||
function installMarketPageBridge(document: Document) {
|
||||
if (
|
||||
document.documentElement.querySelector(
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { MarketRecord } from "./types";
|
||||
import type {
|
||||
AudienceProfileDistributionItem,
|
||||
AudienceProfileKind,
|
||||
AudienceProfileResult,
|
||||
AudienceProfileSuccess
|
||||
} from "./audience-profile-types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export type AudienceProfileRequestTarget =
|
||||
| {
|
||||
linkType: number;
|
||||
source: "audienceDistribution";
|
||||
}
|
||||
| {
|
||||
authorType: number;
|
||||
source: "fansDistribution";
|
||||
};
|
||||
|
||||
interface AudienceProfileClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
type DistributionSection =
|
||||
| "age"
|
||||
| "cityTier"
|
||||
| "cityTop"
|
||||
| "crowd"
|
||||
| "gender"
|
||||
| "interest"
|
||||
| "province";
|
||||
|
||||
const SECTION_BY_DISPLAY: Array<[RegExp, DistributionSection]> = [
|
||||
[/性别/, "gender"],
|
||||
[/年龄/, "age"],
|
||||
[/省份|全国省份/, "province"],
|
||||
[/城市分布|地域/, "cityTop"],
|
||||
[/城市等级/, "cityTier"],
|
||||
[/兴趣/, "interest"],
|
||||
[/八大人群/, "crowd"]
|
||||
];
|
||||
|
||||
const GENDER_LABELS: Record<string, string> = {
|
||||
female: "女性",
|
||||
male: "男性"
|
||||
};
|
||||
|
||||
const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
|
||||
|
||||
export const AUDIENCE_PROFILE_TARGETS: Record<
|
||||
AudienceProfileKind,
|
||||
AudienceProfileRequestTarget
|
||||
> = {
|
||||
audience: { linkType: 5, source: "audienceDistribution" },
|
||||
fans: { authorType: 1, source: "fansDistribution" },
|
||||
longtimeFans: { authorType: 5, source: "fansDistribution" }
|
||||
};
|
||||
|
||||
export function createAudienceProfileClient(
|
||||
options: AudienceProfileClientOptions = {}
|
||||
) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAudienceProfile(
|
||||
record: MarketRecord,
|
||||
target: AudienceProfileRequestTarget
|
||||
): Promise<AudienceProfileResult> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
buildAudienceProfileUrl(record.authorId, baseUrl, target),
|
||||
{
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
failureReason: "request-failed",
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
return mapAudienceProfileResponse(await response.json());
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason:
|
||||
error instanceof Error && error.name === "AbortError"
|
||||
? "timeout"
|
||||
: "request-failed",
|
||||
status: "failed"
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAudienceProfileUrl(
|
||||
authorId: string,
|
||||
baseUrl: string,
|
||||
target: AudienceProfileRequestTarget
|
||||
): string {
|
||||
const url = new URL(
|
||||
target.source === "audienceDistribution"
|
||||
? "/gw/api/data_sp/author_audience_distribution"
|
||||
: "/gw/api/data_sp/get_author_fans_distribution",
|
||||
baseUrl
|
||||
);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
if (target.source === "audienceDistribution") {
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("link_type", String(target.linkType));
|
||||
} else {
|
||||
url.searchParams.set("author_type", String(target.authorType));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function mapAudienceProfileResponse(
|
||||
payload: unknown
|
||||
): AudienceProfileResult {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.distributions)) {
|
||||
return {
|
||||
failureReason: "bad-response",
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
const profile: AudienceProfileSuccess = {
|
||||
status: "success"
|
||||
};
|
||||
|
||||
payload.distributions.forEach((section) => {
|
||||
if (!isRecord(section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const display = readString(section.type_display);
|
||||
const sectionName = resolveSection(display);
|
||||
if (!sectionName || !Array.isArray(section.distribution_list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
profile[sectionName] = normalizeDistributionItems(
|
||||
section.distribution_list,
|
||||
sectionName
|
||||
);
|
||||
});
|
||||
|
||||
if (Object.keys(profile).length === 1) {
|
||||
return {
|
||||
failureReason: "missing-profile",
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
rawItems: unknown[],
|
||||
sectionName: DistributionSection
|
||||
): AudienceProfileDistributionItem[] {
|
||||
const parsedItems = rawItems
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = readString(item.distribution_key);
|
||||
const value = readNumber(item.distribution_value);
|
||||
if (!key || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: normalizeLabel(key, sectionName),
|
||||
rawLabel: key,
|
||||
value
|
||||
};
|
||||
})
|
||||
.filter((item): item is { label: string; rawLabel: string; value: number } =>
|
||||
Boolean(item)
|
||||
);
|
||||
|
||||
const total = parsedItems.reduce((sum, item) => sum + item.value, 0);
|
||||
if (total <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsedItems
|
||||
.sort((left, right) => compareDistributionItems(left, right, sectionName))
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
value: formatPercent(item.value / total)
|
||||
}));
|
||||
}
|
||||
|
||||
function compareDistributionItems(
|
||||
left: { rawLabel: string; value: number },
|
||||
right: { rawLabel: string; value: number },
|
||||
sectionName: DistributionSection
|
||||
): number {
|
||||
if (sectionName === "age") {
|
||||
return orderIndex(AGE_ORDER, left.rawLabel) - orderIndex(AGE_ORDER, right.rawLabel);
|
||||
}
|
||||
|
||||
if (sectionName === "cityTier") {
|
||||
return (
|
||||
orderIndex(CITY_TIER_ORDER, left.rawLabel) -
|
||||
orderIndex(CITY_TIER_ORDER, right.rawLabel)
|
||||
);
|
||||
}
|
||||
|
||||
return right.value - left.value;
|
||||
}
|
||||
|
||||
function orderIndex(order: string[], value: string): number {
|
||||
const index = order.indexOf(value);
|
||||
return index === -1 ? order.length : index;
|
||||
}
|
||||
|
||||
function normalizeLabel(label: string, sectionName: DistributionSection): string {
|
||||
if (sectionName === "gender") {
|
||||
return GENDER_LABELS[label] ?? label;
|
||||
}
|
||||
|
||||
if (sectionName === "cityTier" && !label.endsWith("城市")) {
|
||||
return `${label}城市`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function resolveSection(display: string | null): DistributionSection | null {
|
||||
if (!display) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
SECTION_BY_DISPLAY.find(([pattern]) => pattern.test(display))?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const percent = Math.round(value * 1000) / 10;
|
||||
return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import {
|
||||
buildMarketCsvColumns,
|
||||
listBackendMetricCsvHeaders,
|
||||
listRateCsvHeaders,
|
||||
type CsvColumn
|
||||
} from "./csv-exporter";
|
||||
import type {
|
||||
AudienceProfileDistributionItem,
|
||||
AudienceProfileExportRow,
|
||||
AudienceProfileKind,
|
||||
AudienceProfileResult,
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics,
|
||||
BusinessAbilityVideoKind,
|
||||
BusinessAbilityVideoMetrics
|
||||
} from "./audience-profile-types";
|
||||
|
||||
type AudienceProfileCsvColumn = {
|
||||
header: string;
|
||||
readValue: (row: AudienceProfileExportRow) => string;
|
||||
};
|
||||
|
||||
export interface AudienceProfileCsvOptions {
|
||||
selectedHeaders?: string[];
|
||||
}
|
||||
|
||||
export type AudienceProfileCsvFieldGroup = {
|
||||
headers: string[];
|
||||
label: string;
|
||||
};
|
||||
|
||||
const PROFILE_LAYOUTS: Array<{
|
||||
includeGender: boolean;
|
||||
kind: AudienceProfileKind;
|
||||
label: string;
|
||||
}> = [
|
||||
{ includeGender: true, kind: "audience", label: "观众画像" },
|
||||
{ includeGender: true, kind: "fans", label: "粉丝画像" },
|
||||
{ includeGender: false, kind: "longtimeFans", label: "铁粉画像" }
|
||||
];
|
||||
|
||||
const GENDER_LABELS = ["男性", "女性"];
|
||||
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||
const CITY_TIER_LABELS = [
|
||||
"一线城市",
|
||||
"二线城市",
|
||||
"三线城市",
|
||||
"四线城市",
|
||||
"五线城市"
|
||||
];
|
||||
const CROWD_LABELS = [
|
||||
"精致妈妈",
|
||||
"都市银发",
|
||||
"新锐白领",
|
||||
"资深中产",
|
||||
"都市蓝领",
|
||||
"Z世代",
|
||||
"小镇中老年",
|
||||
"小镇青年"
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_LAYOUTS: Array<{
|
||||
key: BusinessAbilityVideoKind;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "personalVideo", label: "个人视频" },
|
||||
{ key: "xingtuVideo", label: "星图视频" }
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
|
||||
key: keyof BusinessAbilityVideoMetrics;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "medianPlay", label: "播放量中位数" },
|
||||
{ key: "finishRate", label: "完播率" },
|
||||
{ key: "interactionRate", label: "互动率" },
|
||||
{ key: "publishedItems", label: "发布作品" },
|
||||
{ key: "averageDuration", label: "平均时长" },
|
||||
{ key: "averageLike", label: "平均点赞" },
|
||||
{ key: "averageComment", label: "平均评论" },
|
||||
{ key: "averageShare", label: "平均转发" }
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
|
||||
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
||||
|
||||
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
||||
key: BusinessAbilityDurationKind;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "oneToTwenty", label: "1-20s视频" },
|
||||
{ key: "twentyToSixty", label: "20-60s视频" },
|
||||
{ key: "overSixty", label: "60s以上视频" }
|
||||
];
|
||||
|
||||
const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
|
||||
key: keyof BusinessAbilityEstimateMetrics;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "expectedCpm", label: "预期CPM" },
|
||||
{ key: "expectedCpe", label: "预期CPE" },
|
||||
{ key: "expectedPlay", label: "预期播放量" },
|
||||
{ key: "hotRate", label: "爆文率" }
|
||||
];
|
||||
|
||||
export function buildAudienceProfileCsv(
|
||||
rows: AudienceProfileExportRow[],
|
||||
options: AudienceProfileCsvOptions = {}
|
||||
): string {
|
||||
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||
const csvColumns = filterAudienceProfileCsvColumns([
|
||||
...marketColumns.map(toMarketColumn),
|
||||
...buildBusinessAbilityColumns(),
|
||||
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||
], options.selectedHeaders);
|
||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||
const rowLines = rows.map((row) =>
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
|
||||
);
|
||||
|
||||
return [headerLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
export function listAudienceProfileCsvHeaders(
|
||||
rows: AudienceProfileExportRow[] = []
|
||||
): string[] {
|
||||
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||
return [
|
||||
...marketColumns.map((column) => column.header),
|
||||
...buildBusinessAbilityColumns().map((column) => column.header),
|
||||
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map(
|
||||
(column) => column.header
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
|
||||
return [
|
||||
{
|
||||
headers: listRateCsvHeaders(),
|
||||
label: "看后搜率"
|
||||
},
|
||||
{
|
||||
headers: listBackendMetricCsvHeaders(),
|
||||
label: "秒思api数据"
|
||||
},
|
||||
{
|
||||
headers: buildBusinessVideoColumns().map((column) => column.header),
|
||||
label: "内容数据"
|
||||
},
|
||||
{
|
||||
headers: buildBusinessEstimateColumns().map((column) => column.header),
|
||||
label: "效果预估"
|
||||
},
|
||||
...PROFILE_LAYOUTS.map((layout) => ({
|
||||
headers: buildProfileColumns(layout).map((column) => column.header),
|
||||
label: layout.label
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
function filterAudienceProfileCsvColumns(
|
||||
columns: AudienceProfileCsvColumn[],
|
||||
selectedHeaders: string[] | undefined
|
||||
): AudienceProfileCsvColumn[] {
|
||||
if (!selectedHeaders) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
|
||||
const selectedHeaderSet = new Set(selectedHeaders);
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
!selectableHeaderSet.has(column.header) ||
|
||||
selectedHeaderSet.has(column.header)
|
||||
);
|
||||
}
|
||||
|
||||
function listAudienceProfileSelectableHeaders(): string[] {
|
||||
return listAudienceProfileSelectableFieldGroups().flatMap(
|
||||
(group) => group.headers
|
||||
);
|
||||
}
|
||||
|
||||
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
||||
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
|
||||
}
|
||||
|
||||
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
|
||||
return [
|
||||
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
|
||||
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
|
||||
readValue: (row: AudienceProfileExportRow) =>
|
||||
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
|
||||
}))
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
||||
return [
|
||||
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
|
||||
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,
|
||||
readValue: (row: AudienceProfileExportRow) =>
|
||||
readBusinessEstimateValue(row, durationLayout.key, metricLayout.key)
|
||||
}))
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function readBusinessVideoValue(
|
||||
row: AudienceProfileExportRow,
|
||||
videoKey: BusinessAbilityVideoKind,
|
||||
metricKey: keyof BusinessAbilityVideoMetrics
|
||||
): string {
|
||||
const businessAbility = row.businessAbility;
|
||||
if (!businessAbility || businessAbility.status !== "success") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
|
||||
}
|
||||
|
||||
function readBusinessEstimateValue(
|
||||
row: AudienceProfileExportRow,
|
||||
durationKey: BusinessAbilityDurationKind,
|
||||
metricKey: keyof BusinessAbilityEstimateMetrics
|
||||
): string {
|
||||
const businessAbility = row.businessAbility;
|
||||
if (!businessAbility || businessAbility.status !== "success") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return businessAbility.estimates[durationKey]?.[metricKey] ?? "";
|
||||
}
|
||||
|
||||
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
|
||||
return {
|
||||
header: column.header,
|
||||
readValue: (row) => column.readValue(row.record)
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfileColumns(layout: {
|
||||
includeGender: boolean;
|
||||
kind: AudienceProfileKind;
|
||||
label: string;
|
||||
}): AudienceProfileCsvColumn[] {
|
||||
const columns: AudienceProfileCsvColumn[] = [];
|
||||
|
||||
if (layout.includeGender) {
|
||||
columns.push(
|
||||
...buildFixedDistributionColumns(
|
||||
layout.label,
|
||||
layout.kind,
|
||||
"gender",
|
||||
GENDER_LABELS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
columns.push(
|
||||
...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS),
|
||||
...buildFixedDistributionColumns(
|
||||
layout.label,
|
||||
layout.kind,
|
||||
"cityTier",
|
||||
CITY_TIER_LABELS
|
||||
),
|
||||
...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS)
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function buildFixedDistributionColumns(
|
||||
prefix: string,
|
||||
kind: AudienceProfileKind,
|
||||
key: "age" | "cityTier" | "crowd" | "gender",
|
||||
labels: string[]
|
||||
): AudienceProfileCsvColumn[] {
|
||||
return labels.map((label) => ({
|
||||
header: `${prefix}-${label}占比`,
|
||||
readValue: (row) => readDistributionValue(row.profiles[kind], key, label)
|
||||
}));
|
||||
}
|
||||
|
||||
function readDistributionValue(
|
||||
profile: AudienceProfileResult,
|
||||
key: "age" | "cityTier" | "crowd" | "gender",
|
||||
label: string
|
||||
): string {
|
||||
if (profile.status !== "success") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
readProfileDistributionItems(profile, key).find(
|
||||
(candidate) => candidate.label === label
|
||||
)?.value ?? "0%"
|
||||
);
|
||||
}
|
||||
|
||||
function readProfileDistributionItems(
|
||||
profile: AudienceProfileResult,
|
||||
key: "age" | "cityTier" | "crowd" | "gender"
|
||||
): AudienceProfileDistributionItem[] {
|
||||
return profile.status === "success" ? profile[key] ?? [] : [];
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
|
||||
|
||||
export function promptForAudienceProfileFields(
|
||||
document: Document,
|
||||
groups: AudienceProfileCsvFieldGroup[],
|
||||
selectedHeaders: string[]
|
||||
): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
const selectableHeaders = groups.flatMap((group) => group.headers);
|
||||
const selectedHeaderSet = new Set(
|
||||
selectedHeaders.filter((header) => selectableHeaders.includes(header))
|
||||
);
|
||||
if (selectedHeaderSet.size === 0) {
|
||||
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
||||
applyOverlayStyles(overlay);
|
||||
|
||||
const dialog = document.createElement("section");
|
||||
applyDialogStyles(dialog);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
applyTitleStyles(title);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
|
||||
applyHintStyles(hint);
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
applyToolbarStyles(toolbar);
|
||||
|
||||
const selectAllButton = document.createElement("button");
|
||||
selectAllButton.type = "button";
|
||||
selectAllButton.textContent = "全选";
|
||||
applySecondaryButtonStyles(selectAllButton);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = "恢复默认";
|
||||
applySecondaryButtonStyles(resetButton);
|
||||
|
||||
toolbar.append(selectAllButton, resetButton);
|
||||
|
||||
const groupContainer = document.createElement("div");
|
||||
applyGroupContainerStyles(groupContainer);
|
||||
|
||||
const fieldInputs: HTMLInputElement[] = [];
|
||||
groups.forEach((group) => {
|
||||
const groupSection = document.createElement("section");
|
||||
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
|
||||
applyGroupSectionStyles(groupSection);
|
||||
|
||||
const groupHeader = document.createElement("label");
|
||||
applyGroupHeaderStyles(groupHeader);
|
||||
|
||||
const groupInput = document.createElement("input");
|
||||
groupInput.type = "checkbox";
|
||||
|
||||
const groupTitle = document.createElement("span");
|
||||
groupTitle.textContent = group.label;
|
||||
|
||||
groupHeader.append(groupInput, groupTitle);
|
||||
|
||||
const fieldList = document.createElement("div");
|
||||
applyFieldListStyles(fieldList);
|
||||
|
||||
const groupFieldInputs = group.headers.map((header) => {
|
||||
const fieldLabel = document.createElement("label");
|
||||
applyFieldLabelStyles(fieldLabel);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.value = header;
|
||||
input.dataset.audienceProfileFieldDialogField = "checkbox";
|
||||
input.checked = selectedHeaderSet.has(header);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = header;
|
||||
|
||||
fieldLabel.append(input, text);
|
||||
fieldList.append(fieldLabel);
|
||||
fieldInputs.push(input);
|
||||
return input;
|
||||
});
|
||||
|
||||
const syncGroupInput = () => {
|
||||
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
|
||||
groupInput.checked = checkedCount === groupFieldInputs.length;
|
||||
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
|
||||
};
|
||||
|
||||
groupInput.addEventListener("change", () => {
|
||||
groupFieldInputs.forEach((input) => {
|
||||
input.checked = groupInput.checked;
|
||||
});
|
||||
syncTitle();
|
||||
});
|
||||
groupFieldInputs.forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
syncGroupInput();
|
||||
syncTitle();
|
||||
});
|
||||
});
|
||||
syncGroupInput();
|
||||
|
||||
groupSection.append(groupHeader, fieldList);
|
||||
groupContainer.append(groupSection);
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
applyActionsStyles(actions);
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.textContent = "取消";
|
||||
applySecondaryButtonStyles(cancelButton);
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.type = "button";
|
||||
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
|
||||
confirmButton.textContent = "保存";
|
||||
applyPrimaryButtonStyles(confirmButton);
|
||||
|
||||
actions.append(cancelButton, confirmButton);
|
||||
dialog.append(title, hint, toolbar, groupContainer, actions);
|
||||
overlay.append(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function syncTitle() {
|
||||
const checkedCount = fieldInputs.filter((input) => input.checked).length;
|
||||
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
|
||||
}
|
||||
|
||||
function close(value: string[] | null) {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
selectAllButton.addEventListener("click", () => {
|
||||
fieldInputs.forEach((input) => {
|
||||
input.checked = true;
|
||||
});
|
||||
syncTitle();
|
||||
syncAllGroupInputs(dialog);
|
||||
});
|
||||
resetButton.addEventListener("click", () => {
|
||||
fieldInputs.forEach((input) => {
|
||||
input.checked = true;
|
||||
});
|
||||
syncTitle();
|
||||
syncAllGroupInputs(dialog);
|
||||
});
|
||||
cancelButton.addEventListener("click", () => close(null));
|
||||
confirmButton.addEventListener("click", () => {
|
||||
const nextHeaders = fieldInputs
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.value);
|
||||
close(nextHeaders);
|
||||
});
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) {
|
||||
close(null);
|
||||
}
|
||||
});
|
||||
syncTitle();
|
||||
});
|
||||
}
|
||||
|
||||
function syncAllGroupInputs(dialog: HTMLElement): void {
|
||||
dialog
|
||||
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]')
|
||||
.forEach((section) => {
|
||||
const groupInput = section.querySelector(":scope > label > input");
|
||||
const fieldInputs = Array.from(
|
||||
section.querySelectorAll(":scope > div input")
|
||||
) as HTMLInputElement[];
|
||||
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkedCount = fieldInputs.filter((input) => input.checked).length;
|
||||
groupInput.checked = checkedCount === fieldInputs.length;
|
||||
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length;
|
||||
});
|
||||
}
|
||||
|
||||
function applyOverlayStyles(overlay: HTMLElement): void {
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.zIndex = "2147483647";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.background = "rgba(15, 23, 42, 0.38)";
|
||||
}
|
||||
|
||||
function applyDialogStyles(dialog: HTMLElement): void {
|
||||
dialog.style.width = "680px";
|
||||
dialog.style.maxWidth = "calc(100vw - 32px)";
|
||||
dialog.style.maxHeight = "calc(100vh - 48px)";
|
||||
dialog.style.display = "flex";
|
||||
dialog.style.flexDirection = "column";
|
||||
dialog.style.background = "#ffffff";
|
||||
dialog.style.borderRadius = "8px";
|
||||
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
|
||||
dialog.style.padding = "20px";
|
||||
dialog.style.boxSizing = "border-box";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0 0 8px";
|
||||
title.style.fontSize = "18px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.color = "#1f2329";
|
||||
}
|
||||
|
||||
function applyHintStyles(hint: HTMLElement): void {
|
||||
hint.style.margin = "0 0 12px";
|
||||
hint.style.fontSize = "13px";
|
||||
hint.style.lineHeight = "20px";
|
||||
hint.style.color = "#64748b";
|
||||
}
|
||||
|
||||
function applyToolbarStyles(toolbar: HTMLElement): void {
|
||||
toolbar.style.display = "flex";
|
||||
toolbar.style.gap = "8px";
|
||||
toolbar.style.marginBottom = "12px";
|
||||
}
|
||||
|
||||
function applyGroupContainerStyles(container: HTMLElement): void {
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.gap = "10px";
|
||||
container.style.overflow = "auto";
|
||||
container.style.paddingRight = "4px";
|
||||
}
|
||||
|
||||
function applyGroupSectionStyles(section: HTMLElement): void {
|
||||
section.style.border = "1px solid #e5e7eb";
|
||||
section.style.borderRadius = "8px";
|
||||
section.style.padding = "10px";
|
||||
}
|
||||
|
||||
function applyGroupHeaderStyles(label: HTMLElement): void {
|
||||
label.style.display = "flex";
|
||||
label.style.alignItems = "center";
|
||||
label.style.gap = "8px";
|
||||
label.style.fontWeight = "700";
|
||||
label.style.color = "#1f2329";
|
||||
label.style.marginBottom = "8px";
|
||||
}
|
||||
|
||||
function applyFieldListStyles(list: HTMLElement): void {
|
||||
list.style.display = "grid";
|
||||
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
|
||||
list.style.gap = "8px";
|
||||
}
|
||||
|
||||
function applyFieldLabelStyles(label: HTMLElement): void {
|
||||
label.style.display = "flex";
|
||||
label.style.alignItems = "center";
|
||||
label.style.gap = "6px";
|
||||
label.style.fontSize = "13px";
|
||||
label.style.lineHeight = "18px";
|
||||
label.style.color = "#374151";
|
||||
}
|
||||
|
||||
function applyActionsStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.justifyContent = "flex-end";
|
||||
actions.style.columnGap = "8px";
|
||||
actions.style.marginTop = "14px";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "32px";
|
||||
button.style.padding = "0 15px";
|
||||
button.style.border = "1px solid #7f1d2d";
|
||||
button.style.borderRadius = "8px";
|
||||
button.style.background = "#7f1d2d";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.fontWeight = "600";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "32px";
|
||||
button.style.padding = "0 15px";
|
||||
button.style.border = "1px solid #d0d7de";
|
||||
button.style.borderRadius = "8px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#1f2329";
|
||||
button.style.fontWeight = "600";
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export type AudienceProfileKind =
|
||||
| "audience"
|
||||
| "fans"
|
||||
| "longtimeFans";
|
||||
|
||||
export interface AudienceProfileDistributionItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AudienceProfileSuccess {
|
||||
age?: AudienceProfileDistributionItem[];
|
||||
cityTier?: AudienceProfileDistributionItem[];
|
||||
cityTop?: AudienceProfileDistributionItem[];
|
||||
crowd?: AudienceProfileDistributionItem[];
|
||||
gender?: AudienceProfileDistributionItem[];
|
||||
interest?: AudienceProfileDistributionItem[];
|
||||
province?: AudienceProfileDistributionItem[];
|
||||
status: "success";
|
||||
}
|
||||
|
||||
export interface AudienceProfileFailure {
|
||||
failureReason?: string;
|
||||
status: "failed";
|
||||
}
|
||||
|
||||
export interface AudienceProfileSet {
|
||||
audience: AudienceProfileResult;
|
||||
fans: AudienceProfileResult;
|
||||
longtimeFans: AudienceProfileResult;
|
||||
}
|
||||
|
||||
export type AudienceProfileResult =
|
||||
| AudienceProfileSuccess
|
||||
| AudienceProfileFailure;
|
||||
|
||||
export interface AudienceProfileExportRow {
|
||||
businessAbility?: BusinessAbilityResult;
|
||||
profiles: AudienceProfileSet;
|
||||
record: MarketRecord;
|
||||
}
|
||||
|
||||
export type BusinessAbilityVideoKind =
|
||||
| "personalVideo"
|
||||
| "xingtuVideo";
|
||||
|
||||
export interface BusinessAbilityVideoMetrics {
|
||||
averageComment: string;
|
||||
averageDuration: string;
|
||||
averageLike: string;
|
||||
averageShare: string;
|
||||
finishRate: string;
|
||||
interactionRate: string;
|
||||
medianPlay: string;
|
||||
publishedItems: string;
|
||||
}
|
||||
|
||||
export type BusinessAbilityDurationKind =
|
||||
| "oneToTwenty"
|
||||
| "twentyToSixty"
|
||||
| "overSixty";
|
||||
|
||||
export interface BusinessAbilityEstimateMetrics {
|
||||
expectedCpe: string;
|
||||
expectedCpm: string;
|
||||
expectedPlay: string;
|
||||
hotRate: string;
|
||||
}
|
||||
|
||||
export interface BusinessAbilitySuccess {
|
||||
estimates: Partial<
|
||||
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
|
||||
>;
|
||||
status: "success";
|
||||
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
|
||||
}
|
||||
|
||||
export interface BusinessAbilityFailure {
|
||||
failureReason?: string;
|
||||
status: "failed";
|
||||
}
|
||||
|
||||
export type BusinessAbilityResult =
|
||||
| BusinessAbilitySuccess
|
||||
| BusinessAbilityFailure;
|
||||
@@ -1,6 +1,7 @@
|
||||
export function renderMarketAuthGate(
|
||||
document: Document,
|
||||
currentWindow: Window
|
||||
currentWindow: Window,
|
||||
message = "请先登录插件"
|
||||
): HTMLElement {
|
||||
const existingGate = document.querySelector(
|
||||
'[data-market-auth-gate="root"]'
|
||||
@@ -13,10 +14,14 @@ export function renderMarketAuthGate(
|
||||
const root = document.createElement("section");
|
||||
root.dataset.marketAuthGate = "root";
|
||||
root.innerHTML = `
|
||||
<strong>请先登录插件</strong>
|
||||
<strong></strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
const title = root.querySelector("strong");
|
||||
if (title) {
|
||||
title.textContent = message;
|
||||
}
|
||||
|
||||
root
|
||||
.querySelector('[data-market-auth-help="button"]')
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
interface AuthorBaseClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function createAuthorBaseClient(options: AuthorBaseClientOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorBaseInfo(authorId: string): Promise<MarketRecord> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
buildAuthorBaseInfoUrl(authorId, baseUrl),
|
||||
{
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return buildFailedRecord(authorId, "request-failed");
|
||||
}
|
||||
|
||||
return mapAuthorBaseInfoResponse(authorId, await response.json());
|
||||
} catch (error) {
|
||||
return buildFailedRecord(
|
||||
authorId,
|
||||
error instanceof Error && error.name === "AbortError"
|
||||
? "timeout"
|
||||
: "request-failed"
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAuthorBaseInfoUrl(
|
||||
authorId: string,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const url = new URL("/gw/api/author/get_author_base_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("recommend", "true");
|
||||
url.searchParams.set("need_sec_uid", "true");
|
||||
url.searchParams.set("need_linkage_info", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function mapAuthorBaseInfoResponse(
|
||||
authorId: string,
|
||||
payload: unknown
|
||||
): MarketRecord {
|
||||
if (!isRecord(payload)) {
|
||||
return buildFailedRecord(authorId, "bad-response");
|
||||
}
|
||||
|
||||
const authorName = readString(payload.nick_name);
|
||||
if (!authorName) {
|
||||
return buildFailedRecord(authorId, "missing-rate");
|
||||
}
|
||||
|
||||
return {
|
||||
authorId,
|
||||
authorName,
|
||||
status: "success"
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailedRecord(
|
||||
authorId: string,
|
||||
failureReason: MarketRecord["failureReason"]
|
||||
): MarketRecord {
|
||||
return {
|
||||
authorId,
|
||||
authorName: "",
|
||||
failureReason,
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export function promptForAuthorIds(document: Document): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.dataset.authorIdDialog = "overlay";
|
||||
applyOverlayStyles(overlay);
|
||||
|
||||
const dialog = document.createElement("section");
|
||||
applyDialogStyles(dialog);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "按星图ID导出";
|
||||
applyTitleStyles(title);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.dataset.authorIdDialogInput = "textarea";
|
||||
textarea.placeholder = "每行一个星图ID,也支持逗号、空格分隔";
|
||||
applyTextareaStyles(textarea);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.textContent = "粘贴客户提供的达人星图ID,确认后将批量导出达人数据。";
|
||||
applyHintStyles(hint);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
applyActionsStyles(actions);
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.textContent = "取消";
|
||||
applySecondaryButtonStyles(cancelButton);
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.type = "button";
|
||||
confirmButton.textContent = "开始导出";
|
||||
applyPrimaryButtonStyles(confirmButton);
|
||||
|
||||
actions.append(cancelButton, confirmButton);
|
||||
dialog.append(title, hint, textarea, actions);
|
||||
overlay.append(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = (value: string | null) => {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
cancelButton.addEventListener("click", () => close(null));
|
||||
confirmButton.addEventListener("click", () => close(textarea.value));
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) {
|
||||
close(null);
|
||||
}
|
||||
});
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function applyOverlayStyles(overlay: HTMLElement): void {
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.zIndex = "2147483647";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.background = "rgba(15, 23, 42, 0.38)";
|
||||
}
|
||||
|
||||
function applyDialogStyles(dialog: HTMLElement): void {
|
||||
dialog.style.width = "520px";
|
||||
dialog.style.maxWidth = "calc(100vw - 32px)";
|
||||
dialog.style.background = "#ffffff";
|
||||
dialog.style.borderRadius = "8px";
|
||||
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
|
||||
dialog.style.padding = "20px";
|
||||
dialog.style.boxSizing = "border-box";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0 0 8px";
|
||||
title.style.fontSize = "18px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.color = "#1f2329";
|
||||
}
|
||||
|
||||
function applyHintStyles(hint: HTMLElement): void {
|
||||
hint.style.margin = "0 0 12px";
|
||||
hint.style.fontSize = "13px";
|
||||
hint.style.lineHeight = "20px";
|
||||
hint.style.color = "#64748b";
|
||||
}
|
||||
|
||||
function applyTextareaStyles(textarea: HTMLTextAreaElement): void {
|
||||
textarea.style.width = "100%";
|
||||
textarea.style.height = "220px";
|
||||
textarea.style.resize = "vertical";
|
||||
textarea.style.border = "1px solid #d0d7de";
|
||||
textarea.style.borderRadius = "6px";
|
||||
textarea.style.padding = "10px";
|
||||
textarea.style.boxSizing = "border-box";
|
||||
textarea.style.fontSize = "13px";
|
||||
textarea.style.lineHeight = "20px";
|
||||
textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
textarea.style.color = "#1f2329";
|
||||
}
|
||||
|
||||
function applyActionsStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.justifyContent = "flex-end";
|
||||
actions.style.columnGap = "8px";
|
||||
actions.style.marginTop = "14px";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "32px";
|
||||
button.style.padding = "0 15px";
|
||||
button.style.border = "1px solid #7f1d2d";
|
||||
button.style.borderRadius = "8px";
|
||||
button.style.background = "#7f1d2d";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.fontWeight = "600";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "32px";
|
||||
button.style.padding = "0 15px";
|
||||
button.style.border = "1px solid #d0d7de";
|
||||
button.style.borderRadius = "8px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#1f2329";
|
||||
button.style.fontWeight = "600";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface ParsedAuthorIds {
|
||||
duplicates: string[];
|
||||
invalidTokens: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
const AUTHOR_ID_PATTERN = /^\d{16,20}$/;
|
||||
|
||||
export function parseAuthorIds(input: string): ParsedAuthorIds {
|
||||
const ids: string[] = [];
|
||||
const duplicates: string[] = [];
|
||||
const invalidTokens: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
input
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((token) => {
|
||||
if (!/^\d+$/.test(token) || !AUTHOR_ID_PATTERN.test(token)) {
|
||||
invalidTokens.push(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(token)) {
|
||||
duplicates.push(token);
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(token);
|
||||
ids.push(token);
|
||||
});
|
||||
|
||||
return {
|
||||
duplicates,
|
||||
ids,
|
||||
invalidTokens
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
const DIALOG_STYLE_ID = "sces-batch-name-dialog-style";
|
||||
|
||||
const activeDialogs = new WeakMap<
|
||||
Document,
|
||||
{
|
||||
input: HTMLInputElement;
|
||||
promise: Promise<string | null>;
|
||||
}
|
||||
>();
|
||||
|
||||
export function promptForBatchName(document: Document): Promise<string | null> {
|
||||
const existingDialog = activeDialogs.get(document);
|
||||
if (existingDialog) {
|
||||
existingDialog.input.focus();
|
||||
existingDialog.input.select();
|
||||
return existingDialog.promise;
|
||||
}
|
||||
|
||||
ensureDialogStyles(document);
|
||||
|
||||
const dialogRoot = document.createElement("div");
|
||||
dialogRoot.dataset.pluginBatchNameDialog = "root";
|
||||
dialogRoot.setAttribute("role", "dialog");
|
||||
dialogRoot.setAttribute("aria-modal", "true");
|
||||
dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title");
|
||||
applyOverlayStyles(dialogRoot);
|
||||
|
||||
const dialogPanel = document.createElement("div");
|
||||
applyPanelStyles(dialogPanel);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.id = "sces-batch-name-title";
|
||||
title.textContent = "提交批次";
|
||||
applyTitleStyles(title);
|
||||
|
||||
const description = document.createElement("p");
|
||||
description.textContent = "请输入批次名称,便于后续在系统中识别和追踪。";
|
||||
applyDescriptionStyles(description);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.dataset.pluginBatchNameInput = "input";
|
||||
input.placeholder = "例如:618达人筛选第一批";
|
||||
input.maxLength = 60;
|
||||
applyInputStyles(input);
|
||||
|
||||
const errorText = document.createElement("p");
|
||||
errorText.dataset.pluginBatchNameError = "text";
|
||||
applyErrorStyles(errorText);
|
||||
|
||||
const buttonRow = document.createElement("div");
|
||||
applyButtonRowStyles(buttonRow);
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.dataset.pluginBatchNameCancel = "button";
|
||||
cancelButton.textContent = "取消";
|
||||
applySecondaryButtonStyles(cancelButton);
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.type = "button";
|
||||
confirmButton.dataset.pluginBatchNameConfirm = "button";
|
||||
confirmButton.textContent = "确认提交";
|
||||
applyPrimaryButtonStyles(confirmButton);
|
||||
|
||||
buttonRow.append(cancelButton, confirmButton);
|
||||
dialogPanel.append(title, description, input, errorText, buttonRow);
|
||||
dialogRoot.appendChild(dialogPanel);
|
||||
document.body.appendChild(dialogRoot);
|
||||
|
||||
const dialogPromise = new Promise<string | null>((resolve) => {
|
||||
const closeDialog = (value: string | null) => {
|
||||
activeDialogs.delete(document);
|
||||
dialogRoot.remove();
|
||||
document.removeEventListener("keydown", handleDocumentKeydown, true);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const submitValue = () => {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
errorText.textContent = "请输入批次名称";
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(value);
|
||||
};
|
||||
|
||||
const handleDocumentKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitValue();
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
if (!input.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorText.textContent = "";
|
||||
input.removeAttribute("aria-invalid");
|
||||
});
|
||||
cancelButton.addEventListener("click", () => {
|
||||
closeDialog(null);
|
||||
});
|
||||
confirmButton.addEventListener("click", () => {
|
||||
submitValue();
|
||||
});
|
||||
dialogRoot.addEventListener("click", (event) => {
|
||||
if (event.target === dialogRoot) {
|
||||
closeDialog(null);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", handleDocumentKeydown, true);
|
||||
});
|
||||
|
||||
activeDialogs.set(document, {
|
||||
input,
|
||||
promise: dialogPromise
|
||||
});
|
||||
|
||||
input.focus();
|
||||
|
||||
return dialogPromise;
|
||||
}
|
||||
|
||||
function ensureDialogStyles(document: Document): void {
|
||||
if (document.getElementById(DIALOG_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = DIALOG_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-batch-name-dialog="root"] {
|
||||
animation: sces-batch-name-fade-in 0.16s ease;
|
||||
}
|
||||
|
||||
@keyframes sces-batch-name-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function applyOverlayStyles(root: HTMLElement): void {
|
||||
root.style.position = "fixed";
|
||||
root.style.inset = "0";
|
||||
root.style.background = "rgba(15, 23, 42, 0.38)";
|
||||
root.style.display = "flex";
|
||||
root.style.alignItems = "center";
|
||||
root.style.justifyContent = "center";
|
||||
root.style.padding = "24px";
|
||||
root.style.zIndex = "2147483647";
|
||||
}
|
||||
|
||||
function applyPanelStyles(panel: HTMLElement): void {
|
||||
panel.style.width = "min(420px, calc(100vw - 32px))";
|
||||
panel.style.background = "#fffaf9";
|
||||
panel.style.border = "1px solid rgba(127, 29, 45, 0.14)";
|
||||
panel.style.borderRadius = "18px";
|
||||
panel.style.boxShadow = "0 28px 70px rgba(15, 23, 42, 0.22)";
|
||||
panel.style.padding = "24px";
|
||||
panel.style.boxSizing = "border-box";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0";
|
||||
title.style.color = "#4c0519";
|
||||
title.style.fontSize = "20px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "28px";
|
||||
}
|
||||
|
||||
function applyDescriptionStyles(description: HTMLElement): void {
|
||||
description.style.margin = "10px 0 0";
|
||||
description.style.color = "#64748b";
|
||||
description.style.fontSize = "13px";
|
||||
description.style.lineHeight = "20px";
|
||||
}
|
||||
|
||||
function applyInputStyles(input: HTMLInputElement): void {
|
||||
input.style.width = "100%";
|
||||
input.style.height = "42px";
|
||||
input.style.marginTop = "18px";
|
||||
input.style.padding = "0 14px";
|
||||
input.style.boxSizing = "border-box";
|
||||
input.style.border = "1px solid #d8c1c6";
|
||||
input.style.borderRadius = "12px";
|
||||
input.style.background = "#ffffff";
|
||||
input.style.color = "#1f2937";
|
||||
input.style.fontSize = "14px";
|
||||
input.style.outline = "none";
|
||||
}
|
||||
|
||||
function applyErrorStyles(errorText: HTMLElement): void {
|
||||
errorText.style.minHeight = "20px";
|
||||
errorText.style.margin = "8px 0 0";
|
||||
errorText.style.color = "#b91c1c";
|
||||
errorText.style.fontSize = "12px";
|
||||
errorText.style.lineHeight = "18px";
|
||||
}
|
||||
|
||||
function applyButtonRowStyles(buttonRow: HTMLElement): void {
|
||||
buttonRow.style.display = "flex";
|
||||
buttonRow.style.justifyContent = "flex-end";
|
||||
buttonRow.style.gap = "10px";
|
||||
buttonRow.style.marginTop = "18px";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "36px";
|
||||
button.style.padding = "0 16px";
|
||||
button.style.border = "1px solid #d7dde6";
|
||||
button.style.borderRadius = "10px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#334155";
|
||||
button.style.fontWeight = "600";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "36px";
|
||||
button.style.padding = "0 16px";
|
||||
button.style.border = "1px solid #7f1d2d";
|
||||
button.style.borderRadius = "10px";
|
||||
button.style.background = "#7f1d2d";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.fontWeight = "600";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
@@ -5,8 +5,8 @@ export interface BatchPayload {
|
||||
authors: Array<{
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorUid?: string;
|
||||
}>;
|
||||
batchId: string;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
creatorName: string;
|
||||
@@ -38,9 +38,9 @@ export function createBatchPayload(options: {
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName
|
||||
authorName: record.authorName,
|
||||
...(record.coreUserId ? { authorUid: record.coreUserId } : {})
|
||||
})),
|
||||
batchId: `${logtoUserId}-${options.createdAt}`,
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { MarketRecord } from "./types";
|
||||
import type {
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics,
|
||||
BusinessAbilityResult,
|
||||
BusinessAbilitySuccess,
|
||||
BusinessAbilityVideoMetrics
|
||||
} from "./audience-profile-types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
interface BusinessAbilityClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const VIDEO_TYPES = {
|
||||
personalVideo: 1,
|
||||
xingtuVideo: 2
|
||||
} as const;
|
||||
|
||||
export function createBusinessAbilityClient(
|
||||
options: BusinessAbilityClientOptions = {}
|
||||
) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
|
||||
const personalVideo = await loadJson(
|
||||
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
|
||||
);
|
||||
const xingtuVideo = await loadJson(
|
||||
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
|
||||
);
|
||||
const estimates = await loadJson(
|
||||
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
||||
);
|
||||
|
||||
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
|
||||
return {
|
||||
failureReason:
|
||||
personalVideo.failureReason ??
|
||||
xingtuVideo.failureReason ??
|
||||
estimates.failureReason,
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
|
||||
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
|
||||
}
|
||||
} satisfies BusinessAbilitySuccess;
|
||||
}
|
||||
};
|
||||
|
||||
async function loadJson(url: string): Promise<
|
||||
| { ok: true; payload: unknown }
|
||||
| { failureReason: string; ok: false }
|
||||
> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { failureReason: "request-failed", ok: false };
|
||||
}
|
||||
|
||||
return { ok: true, payload: await response.json() };
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason:
|
||||
error instanceof Error && error.name === "AbortError"
|
||||
? "timeout"
|
||||
: "request-failed",
|
||||
ok: false
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBusinessAbilityVideoUrl(
|
||||
authorId: string,
|
||||
baseUrl: string,
|
||||
videoType: number
|
||||
): string {
|
||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("type", String(videoType));
|
||||
url.searchParams.set("flow_type", "0");
|
||||
url.searchParams.set("only_assign", "true");
|
||||
url.searchParams.set("range", "2");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildBusinessAbilityEstimateUrl(
|
||||
authorId: string,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const url = new URL(
|
||||
"/gw/api/aggregator/get_author_commerce_spread_info",
|
||||
baseUrl
|
||||
);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function mapBusinessAbilityVideoResponse(
|
||||
payload: unknown
|
||||
): BusinessAbilityVideoMetrics {
|
||||
const data = getPayloadData(payload);
|
||||
|
||||
return {
|
||||
averageComment: formatWan(readNumber(data?.comment_avg)),
|
||||
averageDuration: formatDuration(readNumber(data?.avg_duration)),
|
||||
averageLike: formatWan(readNumber(data?.like_avg)),
|
||||
averageShare: formatWan(readNumber(data?.share_avg)),
|
||||
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
|
||||
interactionRate: formatBasisPointRate(
|
||||
readNestedNumber(data, "interact_rate", "value")
|
||||
),
|
||||
medianPlay: formatWan(readNumber(data?.play_mid)),
|
||||
publishedItems: formatPublishedItems(readNumber(data?.item_num))
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBusinessAbilityEstimateResponse(
|
||||
payload: unknown
|
||||
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
|
||||
const data = getPayloadData(payload);
|
||||
const expectedPlay = formatWan(readNumber(data?.vv));
|
||||
const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate));
|
||||
|
||||
return {
|
||||
oneToTwenty: {
|
||||
expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1),
|
||||
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1),
|
||||
expectedPlay,
|
||||
hotRate
|
||||
},
|
||||
overSixty: {
|
||||
expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1),
|
||||
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1),
|
||||
expectedPlay,
|
||||
hotRate
|
||||
},
|
||||
twentyToSixty: {
|
||||
expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1),
|
||||
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1),
|
||||
expectedPlay,
|
||||
hotRate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatPublishedItems(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100, 0)}s`;
|
||||
}
|
||||
|
||||
function formatBasisPointRate(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100, 1)}%`;
|
||||
}
|
||||
|
||||
function formatDecimalRate(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "缺失";
|
||||
}
|
||||
|
||||
return `${formatDecimal(value * 100, 0)}%`;
|
||||
}
|
||||
|
||||
function formatWan(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 10000) {
|
||||
return `${formatDecimal(value / 10000, 1)}w`;
|
||||
}
|
||||
|
||||
return formatDecimal(value, 0);
|
||||
}
|
||||
|
||||
function formatDecimal(value: number | null, digits: number): string {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const fixed = value.toFixed(digits);
|
||||
return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
|
||||
}
|
||||
|
||||
function formatFixedDecimal(value: number | null, digits: number): string {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
function readNestedNumber(
|
||||
data: Record<string, unknown> | null,
|
||||
objectKey: string,
|
||||
valueKey: string
|
||||
): number | null {
|
||||
const objectValue = data?.[objectKey];
|
||||
if (!isRecord(objectValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readNumber(objectValue[valueKey]);
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isRecord(payload.data) ? payload.data : payload;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
type CsvColumn = {
|
||||
export type CsvColumn = {
|
||||
header: string;
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
@@ -28,7 +28,7 @@ const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
|
||||
const RATE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "单视频看后搜率",
|
||||
header: "商单视频看后搜率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.rates?.singleVideoAfterSearchRate
|
||||
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
|
||||
@@ -45,38 +45,45 @@ const RATE_COLUMNS: CsvColumn[] = [
|
||||
|
||||
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "看后搜率",
|
||||
header: "秒思api-看后搜率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.afterViewSearchRate ?? ""
|
||||
},
|
||||
{
|
||||
header: "看后搜数",
|
||||
header: "秒思api-看后搜数",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.afterViewSearchCount ?? ""
|
||||
},
|
||||
{
|
||||
header: "新增A3数",
|
||||
header: "秒思api-新增A3数",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.a3IncreaseCount ?? ""
|
||||
},
|
||||
{
|
||||
header: "新增A3率",
|
||||
header: "秒思api-新增A3率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.newA3Rate ?? ""
|
||||
},
|
||||
{
|
||||
header: "CPA3",
|
||||
header: "秒思api-CPA3",
|
||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
|
||||
},
|
||||
{
|
||||
header: "cp_search",
|
||||
header: "秒思api-cp_search",
|
||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
|
||||
}
|
||||
];
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function listBackendMetricCsvHeaders(): string[] {
|
||||
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||
const csvColumns = buildMarketCsvColumns(records);
|
||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||
const rowLines = records.map((record) =>
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
@@ -85,7 +92,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
return [headerLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||
}
|
||||
|
||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const orderedHeaders: string[] = [];
|
||||
const seenHeaders = new Set<string>();
|
||||
const excludedHeaders = new Set(["代表视频"]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeFractionRateDisplay,
|
||||
normalizeRateDisplay
|
||||
} from "../../shared/rate-normalizer";
|
||||
import { mapMarketListRow } from "./market-list-row";
|
||||
import type {
|
||||
AfterSearchRates,
|
||||
BackendMetrics,
|
||||
@@ -69,6 +70,7 @@ type RowOrderTarget = {
|
||||
type MarketDataRow = {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
coreUserId?: string;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource: boolean;
|
||||
location?: string;
|
||||
@@ -296,7 +298,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
|
||||
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
|
||||
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
|
||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "商单视频看后搜率");
|
||||
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
|
||||
ensureSyntheticHeaderCell(header, field, label);
|
||||
@@ -431,7 +433,7 @@ function readDivGridAuthorIds(root: ParentNode): string[] | null {
|
||||
) ?? null
|
||||
: null;
|
||||
const authorColumn = authorSection
|
||||
? getDirectContentColumns(authorSection)[0] ?? null
|
||||
? getNativeAuthorColumn(authorSection)
|
||||
: null;
|
||||
if (!authorColumn) {
|
||||
return null;
|
||||
@@ -497,7 +499,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
type: "body"
|
||||
});
|
||||
|
||||
const authorColumn = getDirectContentColumns(authorSection)[0] ?? null;
|
||||
const authorColumn = getNativeAuthorColumn(authorSection);
|
||||
const actionColumn = getActionColumn(rightSection);
|
||||
|
||||
if (!authorColumn || !actionColumn) {
|
||||
@@ -530,7 +532,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
pluginHeaderSection,
|
||||
headerTemplateCell,
|
||||
SINGLE_COLUMN_KEY,
|
||||
"单视频看后搜率"
|
||||
"商单视频看后搜率"
|
||||
);
|
||||
ensureDivHeaderCell(
|
||||
pluginHeaderSection,
|
||||
@@ -752,7 +754,11 @@ function ensureDivHeaderCell(
|
||||
nextCell.textContent = label;
|
||||
applyColumnWidth(nextCell, field);
|
||||
applyPluginHeaderCellStyles(nextCell);
|
||||
if (field === SELECTION_COLUMN_KEY) {
|
||||
container.insertBefore(nextCell, templateCell);
|
||||
} else {
|
||||
container.appendChild(nextCell);
|
||||
}
|
||||
return nextCell;
|
||||
}
|
||||
|
||||
@@ -774,7 +780,11 @@ function ensureDivBodyColumn(
|
||||
nextColumn.dataset.marketColumnGroup = field;
|
||||
applyColumnWidth(nextColumn, field);
|
||||
syncDivColumnCells(nextColumn, templateColumn, field, rowCount);
|
||||
if (field === SELECTION_COLUMN_KEY) {
|
||||
container.insertBefore(nextColumn, templateColumn);
|
||||
} else {
|
||||
container.appendChild(nextColumn);
|
||||
}
|
||||
return nextColumn;
|
||||
}
|
||||
|
||||
@@ -792,28 +802,51 @@ function syncDivColumnCells(
|
||||
const templateCells = getDirectContentCells(templateColumn);
|
||||
for (let index = 0; index < rowCount; index += 1) {
|
||||
const existingCell = getDirectContentCells(column)[index] ?? null;
|
||||
const templateCell =
|
||||
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
|
||||
if (existingCell) {
|
||||
existingCell.dataset.marketRowCell = field;
|
||||
applyPluginContentCellStyles(existingCell);
|
||||
syncContentCellHeight(existingCell, templateCell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const templateCell =
|
||||
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
|
||||
const nextCell =
|
||||
field === SELECTION_COLUMN_KEY
|
||||
? createBareContentCell(column.ownerDocument)
|
||||
? templateCell
|
||||
? createSelectionContentCell(templateCell)
|
||||
: createBareContentCell(column.ownerDocument)
|
||||
: templateCell
|
||||
? cloneElementShallow(templateCell)
|
||||
: createBareContentCell(column.ownerDocument);
|
||||
nextCell.dataset.marketRowCell = field;
|
||||
applyColumnWidth(nextCell, field);
|
||||
applyPluginContentCellStyles(nextCell);
|
||||
syncContentCellHeight(nextCell, templateCell);
|
||||
nextCell.textContent = "";
|
||||
column.appendChild(nextCell);
|
||||
}
|
||||
}
|
||||
|
||||
function syncContentCellHeight(
|
||||
cell: HTMLElement,
|
||||
templateCell: HTMLElement | null
|
||||
): void {
|
||||
if (!templateCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measuredHeight = Math.round(templateCell.getBoundingClientRect().height);
|
||||
const nextHeight =
|
||||
measuredHeight > 0 ? `${measuredHeight}px` : templateCell.style.height;
|
||||
|
||||
if (nextHeight) {
|
||||
cell.style.height = nextHeight;
|
||||
} else {
|
||||
cell.style.removeProperty("height");
|
||||
}
|
||||
}
|
||||
|
||||
function applyPluginHeaderCellStyles(cell: HTMLElement): void {
|
||||
cell.style.display = "flex";
|
||||
cell.style.alignItems = "center";
|
||||
@@ -1106,6 +1139,21 @@ function getActionColumn(bodySection: HTMLElement): HTMLElement | null {
|
||||
return columns[columns.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function getNativeAuthorColumn(authorSection: HTMLElement): HTMLElement | null {
|
||||
return (
|
||||
getDirectContentColumns(authorSection).find(
|
||||
(column) =>
|
||||
!column.dataset.marketColumnGroup &&
|
||||
getDirectContentCells(column).some(
|
||||
(cell) =>
|
||||
cell.querySelector("a") ||
|
||||
cell.querySelector(".author-nickname") ||
|
||||
Boolean(cell.dataset.authorId)
|
||||
)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectHeaderCells(section: Element): HTMLElement[] {
|
||||
return Array.from(section.querySelectorAll(".header-cell")).filter(
|
||||
(cell): cell is HTMLElement =>
|
||||
@@ -1162,6 +1210,13 @@ function createBareContentCell(document: Document): HTMLElement {
|
||||
return cell;
|
||||
}
|
||||
|
||||
function createSelectionContentCell(templateCell: HTMLElement): HTMLElement {
|
||||
const cell = cloneElementShallow(templateCell);
|
||||
cell.removeAttribute("data-testid");
|
||||
cell.removeAttribute("data-author-id");
|
||||
return cell;
|
||||
}
|
||||
|
||||
function extractAuthorId(authorCell: HTMLElement): string {
|
||||
const explicitAuthorId = authorCell.dataset.authorId;
|
||||
if (explicitAuthorId) {
|
||||
@@ -1221,33 +1276,9 @@ function readVueMarketRows(
|
||||
continue;
|
||||
}
|
||||
|
||||
return marketList.map((row) => {
|
||||
const record = isRecord(row) ? row : {};
|
||||
const attributeDatas = readMarketAttributeDatas(record);
|
||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
||||
readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
|
||||
);
|
||||
|
||||
return {
|
||||
authorId:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "id")) ??
|
||||
"",
|
||||
authorName:
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
|
||||
"",
|
||||
exportFields: buildMarketExportFieldFallbacks(record, attributeDatas),
|
||||
hasDirectRatesSource: true,
|
||||
location: readMarketLocation(record, attributeDatas),
|
||||
price21To60s: readMarketPrice21To60s(record, attributeDatas),
|
||||
rates: singleVideoAfterSearchRate
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
return marketList
|
||||
.map((row) => (isRecord(row) ? mapMarketListRow(row) : null))
|
||||
.filter((row): row is MarketDataRow => row !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1311,6 +1342,7 @@ function readSerializedMarketRows(
|
||||
return {
|
||||
authorId: readString(record.authorId) ?? "",
|
||||
authorName: readString(record.authorName) ?? "",
|
||||
coreUserId: readString(record.coreUserId) ?? undefined,
|
||||
exportFields: readSerializedExportFields(record),
|
||||
hasDirectRatesSource: Boolean(singleVideoAfterSearchRate),
|
||||
location: readString(record.location) ?? undefined,
|
||||
@@ -1396,7 +1428,7 @@ function shouldExportColumn(label: string): boolean {
|
||||
label !== ACTION_HEADER_TEXT &&
|
||||
label !== BACKEND_HEADER_TEXT &&
|
||||
!excludedBackendLabels.has(label) &&
|
||||
label !== "单视频看后搜率" &&
|
||||
label !== "商单视频看后搜率" &&
|
||||
label !== "个人视频看后搜率"
|
||||
);
|
||||
}
|
||||
@@ -1632,6 +1664,7 @@ function mergeMarketDataRows(
|
||||
return {
|
||||
authorId: preferredRow.authorId || baseRow.authorId,
|
||||
authorName: preferredRow.authorName || baseRow.authorName,
|
||||
coreUserId: mergeNonEmptyString(baseRow.coreUserId, preferredRow.coreUserId),
|
||||
exportFields: mergeExportFieldMaps(baseRow.exportFields, preferredRow.exportFields),
|
||||
hasDirectRatesSource:
|
||||
preferredRow.hasDirectRatesSource || baseRow.hasDirectRatesSource,
|
||||
|
||||
@@ -222,6 +222,7 @@ function mergeMarketRecord(
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
incomingRecord.exportFields
|
||||
|
||||
+724
-113
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
export const MARKET_REQUEST_SNAPSHOT_ATTRIBUTE =
|
||||
"data-sces-market-request-snapshot";
|
||||
const MARKET_SEARCH_ENDPOINT_PATH = "/gw/api/gsearch/search_for_author_square";
|
||||
|
||||
export interface MarketListRequestSnapshot {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function readMarketListRequestSnapshot(
|
||||
document: Document
|
||||
): MarketListRequestSnapshot | null {
|
||||
const serializedSnapshot = document.documentElement.getAttribute(
|
||||
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE
|
||||
);
|
||||
if (!serializedSnapshot) {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedSnapshot = normalizeMarketListRequestSnapshot(
|
||||
JSON.parse(serializedSnapshot)
|
||||
);
|
||||
if (!parsedSnapshot) {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
|
||||
return parsedSnapshot;
|
||||
} catch {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMarketListRequestSnapshot(
|
||||
document: Document,
|
||||
snapshot: MarketListRequestSnapshot
|
||||
): void {
|
||||
document.documentElement.setAttribute(
|
||||
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
|
||||
JSON.stringify(snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
function isMarketListRequestSnapshot(
|
||||
value: unknown
|
||||
): value is MarketListRequestSnapshot {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MarketListRequestSnapshot>;
|
||||
return (
|
||||
typeof candidate.method === "string" &&
|
||||
typeof candidate.url === "string" &&
|
||||
(!("body" in candidate) || typeof candidate.body === "string") &&
|
||||
(!("headers" in candidate) || isStringRecord(candidate.headers))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMarketListRequestSnapshot(
|
||||
value: unknown
|
||||
): MarketListRequestSnapshot | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MarketListRequestSnapshot> & {
|
||||
headers?: Record<string, unknown>;
|
||||
};
|
||||
const normalizedSnapshot: Partial<MarketListRequestSnapshot> = {
|
||||
body: typeof candidate.body === "string" ? candidate.body : undefined,
|
||||
method: typeof candidate.method === "string" ? candidate.method : undefined,
|
||||
url: typeof candidate.url === "string" ? candidate.url : undefined
|
||||
};
|
||||
|
||||
if (candidate.headers && typeof candidate.headers === "object") {
|
||||
normalizedSnapshot.headers = Object.fromEntries(
|
||||
Object.entries(candidate.headers)
|
||||
.filter(([, entry]) =>
|
||||
["string", "number", "boolean"].includes(typeof entry)
|
||||
)
|
||||
.map(([key, entry]) => [key, String(entry)])
|
||||
);
|
||||
}
|
||||
|
||||
return isMarketListRequestSnapshot(normalizedSnapshot)
|
||||
? normalizedSnapshot
|
||||
: null;
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function readMarketListRequestSnapshotFromPageState(
|
||||
document: Document
|
||||
): MarketListRequestSnapshot | null {
|
||||
const reqParams = findMarketReqParams(document);
|
||||
if (!reqParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify(reqParams),
|
||||
method: "POST",
|
||||
url: buildMarketSearchUrl(document)
|
||||
};
|
||||
}
|
||||
|
||||
function findMarketReqParams(document: Document): Record<string, unknown> | null {
|
||||
const marketRoot = document.querySelector(".base-author-list") as
|
||||
| (Element & {
|
||||
__vue__?: {
|
||||
_setupState?: Record<string, unknown>;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
const setupState = marketRoot?.__vue__?._setupState;
|
||||
if (!setupState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue: unknown[] = Object.values(setupState);
|
||||
while (queue.length > 0) {
|
||||
const current = unwrapVueRef(queue.shift());
|
||||
if (!isRecord(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reqParams = unwrapVueRef(current.reqParams);
|
||||
if (isRecord(reqParams)) {
|
||||
return reqParams;
|
||||
}
|
||||
|
||||
Object.values(current).forEach((value) => {
|
||||
queue.push(value);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMarketSearchUrl(document: Document): string {
|
||||
if (
|
||||
document.location?.origin &&
|
||||
document.location.origin !== "null" &&
|
||||
document.location.origin !== "about:blank"
|
||||
) {
|
||||
return document.location.origin.includes("xingtu.cn")
|
||||
? MARKET_SEARCH_ENDPOINT_PATH
|
||||
: new URL(MARKET_SEARCH_ENDPOINT_PATH, document.location.origin).toString();
|
||||
}
|
||||
|
||||
return MARKET_SEARCH_ENDPOINT_PATH;
|
||||
}
|
||||
|
||||
function unwrapVueRef(value: unknown): unknown {
|
||||
if (isRecord(value) && "value" in value) {
|
||||
return value.value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
|
||||
import type { MarketRowSnapshot } from "./types";
|
||||
|
||||
export interface ParsedMarketListResponse {
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
records: MarketRowSnapshot[];
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
const PAGE_NUMBER_KEYS = [
|
||||
"currentPage",
|
||||
"page",
|
||||
"pageNo",
|
||||
"pageNum",
|
||||
"page_no",
|
||||
"page_num"
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZE_KEYS = [
|
||||
"limit",
|
||||
"pageSize",
|
||||
"page_size",
|
||||
"size"
|
||||
] as const;
|
||||
|
||||
const TOTAL_COUNT_KEYS = [
|
||||
"total",
|
||||
"totalCount",
|
||||
"total_count"
|
||||
] as const;
|
||||
|
||||
const TOTAL_PAGE_KEYS = [
|
||||
"pageCount",
|
||||
"page_count",
|
||||
"totalPage",
|
||||
"totalPages",
|
||||
"total_page",
|
||||
"total_pages"
|
||||
] as const;
|
||||
|
||||
export function mapMarketListRow(
|
||||
row: Record<string, unknown>
|
||||
): MarketRowSnapshot {
|
||||
const attributeDatas = readMarketAttributeDatas(row);
|
||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
||||
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
|
||||
);
|
||||
|
||||
return {
|
||||
authorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "star_id")) ??
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ??
|
||||
"",
|
||||
authorName:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ??
|
||||
"",
|
||||
coreUserId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ??
|
||||
undefined,
|
||||
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
|
||||
hasDirectRatesSource: true,
|
||||
location: readMarketLocation(row, attributeDatas),
|
||||
price21To60s: readMarketPrice21To60s(row, attributeDatas),
|
||||
rates: singleVideoAfterSearchRate
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarketListResponse(
|
||||
payload: unknown
|
||||
): ParsedMarketListResponse | null {
|
||||
const container = findMarketListContainer(payload);
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const marketList = readMarketListArray(container);
|
||||
if (!marketList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? undefined,
|
||||
pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? undefined,
|
||||
records: marketList
|
||||
.map((row) => (isRecord(row) ? mapMarketListRow(row) : null))
|
||||
.filter(
|
||||
(row): row is MarketRowSnapshot =>
|
||||
row !== null && Boolean(row.authorId || row.authorName)
|
||||
),
|
||||
totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? undefined,
|
||||
totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function readKnownPaginationNumber(
|
||||
value: unknown,
|
||||
kind: "page" | "pageSize"
|
||||
): number | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readKnownNumberDeep(value, kind === "page" ? PAGE_NUMBER_KEYS : PAGE_SIZE_KEYS);
|
||||
}
|
||||
|
||||
function findMarketListContainer(value: unknown): Record<string, unknown> | null {
|
||||
const queue: unknown[] = [value];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!isRecord(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (readMarketListArray(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Object.values(current).forEach((entry) => {
|
||||
queue.push(unwrapVueRef(entry));
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMarketListArray(record: Record<string, unknown>): unknown[] | null {
|
||||
const marketList = unwrapVueRef(record.marketList);
|
||||
if (Array.isArray(marketList)) {
|
||||
return marketList;
|
||||
}
|
||||
|
||||
const authors = unwrapVueRef(record.authors);
|
||||
if (Array.isArray(authors)) {
|
||||
return authors;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapVueRef(value: unknown): unknown {
|
||||
if (isRecord(value) && "value" in value) {
|
||||
return value.value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMarketAttributeDatas(
|
||||
record: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
|
||||
}
|
||||
|
||||
function readMarketFieldValue(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>,
|
||||
field: string
|
||||
): unknown {
|
||||
return record[field] ?? attributeDatas[field];
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function normalizeMarketListRate(value: unknown): string | null {
|
||||
if (typeof value === "number") {
|
||||
return normalizeFractionRateDisplay(String(value));
|
||||
}
|
||||
|
||||
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
||||
}
|
||||
|
||||
function normalizeExportCellText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function buildMarketExportFieldFallbacks(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): Record<string, string> | undefined {
|
||||
const exportFields: Record<string, string> = {};
|
||||
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
|
||||
const authorType = buildMarketAuthorType(record, attributeDatas);
|
||||
const contentTheme = buildMarketContentTheme(record, attributeDatas);
|
||||
const connectedUsers = formatWanValue(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
|
||||
);
|
||||
const followerCount = formatWanValue(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
|
||||
);
|
||||
const expectedCpm = formatDecimalDisplay(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
|
||||
);
|
||||
const expectedPlayCount = formatWanValue(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
|
||||
);
|
||||
const interactionRate = formatFractionPercent(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
|
||||
);
|
||||
const finishRate = formatFractionPercent(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
|
||||
);
|
||||
const burstRate = readBurstRateDisplay(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
|
||||
);
|
||||
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
|
||||
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
|
||||
|
||||
assignExportField(exportFields, "达人信息", authorInfo);
|
||||
assignExportField(exportFields, "代表视频", representativeVideo);
|
||||
assignExportField(exportFields, "达人类型", authorType);
|
||||
assignExportField(exportFields, "内容主题", contentTheme);
|
||||
assignExportField(exportFields, "连接用户数", connectedUsers);
|
||||
assignExportField(exportFields, "粉丝数", followerCount);
|
||||
assignExportField(exportFields, "预期CPM", expectedCpm);
|
||||
assignExportField(exportFields, "预期播放量", expectedPlayCount);
|
||||
assignExportField(exportFields, "互动率", interactionRate);
|
||||
assignExportField(exportFields, "完播率", finishRate);
|
||||
assignExportField(exportFields, "爆文率", burstRate);
|
||||
assignExportField(exportFields, "21-60s报价", price21To60s);
|
||||
|
||||
return Object.keys(exportFields).length > 0 ? exportFields : undefined;
|
||||
}
|
||||
|
||||
function assignExportField(
|
||||
exportFields: Record<string, string>,
|
||||
key: string,
|
||||
value: string | undefined
|
||||
): void {
|
||||
if (hasTextValue(value)) {
|
||||
exportFields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined | null): boolean {
|
||||
return Boolean(value && value.trim().length > 0);
|
||||
}
|
||||
|
||||
function buildMarketAuthorInfo(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const nickname =
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
|
||||
"";
|
||||
const parts = [
|
||||
nickname,
|
||||
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
|
||||
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
|
||||
].filter((value) => Boolean(value));
|
||||
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function buildMarketAuthorType(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const tagsRelation = readRecordLike(
|
||||
readMarketFieldValue(record, attributeDatas, "tags_relation")
|
||||
);
|
||||
if (tagsRelation) {
|
||||
const primaryTag = Object.keys(tagsRelation)[0];
|
||||
if (hasTextValue(primaryTag)) {
|
||||
return primaryTag;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMarketContentTheme(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const themes = readStringArray(
|
||||
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
|
||||
);
|
||||
if (themes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (themes.length <= 2) {
|
||||
return themes.join(" ");
|
||||
}
|
||||
|
||||
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
|
||||
}
|
||||
|
||||
function readMarketLocation(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined;
|
||||
}
|
||||
|
||||
function readMarketPrice21To60s(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
return formatCurrencyValue(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
|
||||
);
|
||||
}
|
||||
|
||||
function readMarketRepresentativeVideo(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
|
||||
for (const item of items) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = readString(item.title);
|
||||
if (hasTextValue(title)) {
|
||||
return normalizeExportCellText(title);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readMarketGenderLabel(value: unknown): string | undefined {
|
||||
const rawValue = typeof value === "number" ? String(value) : readString(value);
|
||||
if (rawValue === "1") {
|
||||
return "男";
|
||||
}
|
||||
|
||||
if (rawValue === "2") {
|
||||
return "女";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readBurstRateDisplay(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value <= 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return formatFractionPercent(value);
|
||||
}
|
||||
|
||||
function formatCurrencyValue(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `¥${value.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 0
|
||||
})}`;
|
||||
}
|
||||
|
||||
function formatWanValue(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimalWithGrouping(value / 10000)}w`;
|
||||
}
|
||||
|
||||
function formatFractionPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimalDisplay(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatDecimalDisplay(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false
|
||||
});
|
||||
}
|
||||
|
||||
function formatDecimalWithGrouping(value: number): string {
|
||||
return value.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function readNumericValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(trimmedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
return Array.isArray(parsedValue)
|
||||
? parsedValue.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function readArrayLike(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
return Array.isArray(parsedValue) ? parsedValue : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function readRecordLike(value: unknown): Record<string, unknown> | null {
|
||||
if (isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
return isRecord(parsedValue) ? parsedValue : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readKnownNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = readNumericValue(record[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readKnownNumberDeep(
|
||||
value: unknown,
|
||||
keys: readonly string[]
|
||||
): number | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directValue = readKnownNumber(value, keys);
|
||||
if (typeof directValue === "number") {
|
||||
return directValue;
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(value)) {
|
||||
const candidate =
|
||||
readKnownNumberDeep(unwrapVueRef(nestedValue), keys);
|
||||
if (typeof candidate === "number") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
|
||||
import {
|
||||
writeMarketListRequestSnapshot
|
||||
} from "./market-list-request-snapshot";
|
||||
import { parseMarketListResponse } from "./market-list-row";
|
||||
|
||||
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
|
||||
const MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
|
||||
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
|
||||
|
||||
type MarketRow = {
|
||||
@@ -24,6 +29,7 @@ function installMarketPageBridge() {
|
||||
}
|
||||
|
||||
window[BRIDGE_MARKER] = true;
|
||||
installMarketRequestSnapshotBridge();
|
||||
syncSerializedMarketRows();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
@@ -39,7 +45,16 @@ function installMarketPageBridge() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function installMarketRequestSnapshotBridge() {
|
||||
installFetchSnapshotBridge();
|
||||
installXmlHttpRequestSnapshotBridge();
|
||||
}
|
||||
|
||||
function syncSerializedMarketRows() {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
|
||||
if (
|
||||
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
|
||||
@@ -52,6 +67,131 @@ function syncSerializedMarketRows() {
|
||||
}
|
||||
}
|
||||
|
||||
function installFetchSnapshotBridge() {
|
||||
if (typeof window.fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestSnapshot = readFetchSnapshot(input, init);
|
||||
const response = await originalFetch(input, init);
|
||||
if (requestSnapshot) {
|
||||
const clonedResponse = response.clone();
|
||||
void captureMarketSnapshotFromResponse(requestSnapshot, () =>
|
||||
clonedResponse.json()
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
function installXmlHttpRequestSnapshotBridge() {
|
||||
const OriginalXmlHttpRequest = window.XMLHttpRequest;
|
||||
if (!OriginalXmlHttpRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalOpen = OriginalXmlHttpRequest.prototype.open;
|
||||
const originalSend = OriginalXmlHttpRequest.prototype.send;
|
||||
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
|
||||
|
||||
OriginalXmlHttpRequest.prototype.open = function (
|
||||
method: string,
|
||||
url: string | URL,
|
||||
...rest: unknown[]
|
||||
) {
|
||||
(
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot = {
|
||||
headers: {},
|
||||
method,
|
||||
url: String(url)
|
||||
};
|
||||
return originalOpen.call(this, method, url, ...(rest as [boolean?, string?, string?]));
|
||||
};
|
||||
|
||||
OriginalXmlHttpRequest.prototype.setRequestHeader = function (
|
||||
name: string,
|
||||
value: string
|
||||
) {
|
||||
(
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot?.headers &&
|
||||
((this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}).__scesMarketSnapshot!.headers[name] = value);
|
||||
return originalSetRequestHeader.call(this, name, value);
|
||||
};
|
||||
|
||||
OriginalXmlHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
const snapshotState = (
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot;
|
||||
if (snapshotState) {
|
||||
snapshotState.body = typeof body === "string" ? body : undefined;
|
||||
this.addEventListener("load", () => {
|
||||
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
void captureMarketSnapshotFromResponse(snapshotState, async () =>
|
||||
JSON.parse(this.responseText)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return originalSend.call(this, body);
|
||||
};
|
||||
}
|
||||
|
||||
async function captureMarketSnapshotFromResponse(
|
||||
snapshot: {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
},
|
||||
readPayload: () => Promise<unknown>
|
||||
) {
|
||||
if (!isMarketSearchRequest(snapshot.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await readPayload();
|
||||
if (!parseMarketListResponse(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeMarketListRequestSnapshot(document, {
|
||||
body: snapshot.body,
|
||||
headers: snapshot.headers,
|
||||
method: snapshot.method,
|
||||
url: snapshot.url
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readSerializedMarketRows() {
|
||||
const marketList = readMarketList();
|
||||
return marketList
|
||||
@@ -65,13 +205,66 @@ function readSerializedMarketRows() {
|
||||
readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
|
||||
authorName:
|
||||
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
||||
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
||||
singleVideoAfterSearchRate
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
}
|
||||
|
||||
function readFetchSnapshot(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
} | null {
|
||||
const request = input instanceof Request ? input : null;
|
||||
const method = init?.method ?? request?.method ?? "GET";
|
||||
const url = request?.url ?? String(input);
|
||||
const body =
|
||||
typeof init?.body === "string"
|
||||
? init.body
|
||||
: typeof request?.bodyUsed === "boolean" && request.bodyUsed
|
||||
? undefined
|
||||
: undefined;
|
||||
const headers = serializeHeaders(init?.headers ?? request?.headers);
|
||||
|
||||
return {
|
||||
body,
|
||||
headers,
|
||||
method,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function serializeHeaders(
|
||||
headers: HeadersInit | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function readMarketList(): MarketRow[] {
|
||||
if (typeof document === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const marketRoot = document.querySelector(".base-author-list") as
|
||||
| (HTMLElement & {
|
||||
__vue__?: {
|
||||
@@ -102,6 +295,15 @@ function readMarketList(): MarketRow[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isMarketSearchRequest(url: string): boolean {
|
||||
return (
|
||||
url === MARKET_SEARCH_REQUEST_PATH ||
|
||||
url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
|
||||
url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
|
||||
url.endsWith(MARKET_SEARCH_REQUEST_PATH)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeMarketList(value: unknown[]): boolean {
|
||||
const firstRow = value[0];
|
||||
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
|
||||
|
||||
@@ -5,10 +5,16 @@ import type {
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginToolbarDom {
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
@@ -17,6 +23,8 @@ export interface PluginToolbarDom {
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
|
||||
|
||||
export function isPluginToolbarMounted(
|
||||
root: HTMLElement,
|
||||
document: Document
|
||||
@@ -29,20 +37,31 @@ export function ensurePluginToolbar(
|
||||
document: Document,
|
||||
handlers: PluginToolbarHandlers
|
||||
): PluginToolbarDom {
|
||||
ensurePluginActionButtonTheme(document);
|
||||
|
||||
const existingRoot = document.querySelector(
|
||||
"[data-plugin-toolbar='root']"
|
||||
) as HTMLElement | null;
|
||||
if (existingRoot) {
|
||||
if (
|
||||
existingRoot.querySelector(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
)
|
||||
) {
|
||||
ensureToolbarMounted(existingRoot, document);
|
||||
return readToolbarDom(existingRoot);
|
||||
}
|
||||
|
||||
existingRoot.remove();
|
||||
}
|
||||
|
||||
const root = document.createElement("section");
|
||||
root.dataset.pluginToolbar = "root";
|
||||
applyToolbarRootStyles(root);
|
||||
|
||||
const exportRangeSelect = document.createElement("select");
|
||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||
exportRangeSelect.hidden = true;
|
||||
appendOption(exportRangeSelect, "current", "当前页");
|
||||
appendOption(exportRangeSelect, "first-5", "前5页");
|
||||
appendOption(exportRangeSelect, "first-10", "前10页");
|
||||
@@ -61,12 +80,35 @@ export function ensurePluginToolbar(
|
||||
const exportButton = document.createElement("button");
|
||||
exportButton.type = "button";
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.textContent = "导出CSV";
|
||||
exportButton.hidden = true;
|
||||
exportButton.tabIndex = -1;
|
||||
|
||||
const audienceProfileExportButton = document.createElement("button");
|
||||
audienceProfileExportButton.type = "button";
|
||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||
audienceProfileExportButton.textContent = "导出选中达人数据";
|
||||
audienceProfileExportButton.title =
|
||||
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
||||
|
||||
const audienceProfileByIdExportButton = document.createElement("button");
|
||||
audienceProfileByIdExportButton.type = "button";
|
||||
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
|
||||
audienceProfileByIdExportButton.textContent = "按星图ID导出";
|
||||
audienceProfileByIdExportButton.title =
|
||||
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
||||
|
||||
const audienceProfileFieldButton = document.createElement("button");
|
||||
audienceProfileFieldButton.type = "button";
|
||||
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
|
||||
audienceProfileFieldButton.textContent = "选择字段";
|
||||
audienceProfileFieldButton.title =
|
||||
"勾选本次CSV需要导出的字段,设置会自动保存";
|
||||
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
|
||||
|
||||
const exportStatusText = document.createElement("span");
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
@@ -76,12 +118,18 @@ export function ensurePluginToolbar(
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
);
|
||||
|
||||
document.body.appendChild(root);
|
||||
applyNativeControlStyles(document, {
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
@@ -92,12 +140,24 @@ export function ensurePluginToolbar(
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
audienceProfileExportButton.addEventListener("click", () => {
|
||||
void handlers.onExportAudienceProfile();
|
||||
});
|
||||
audienceProfileByIdExportButton.addEventListener("click", () => {
|
||||
void handlers.onExportAudienceProfileByIds();
|
||||
});
|
||||
audienceProfileFieldButton.addEventListener("click", () => {
|
||||
void handlers.onConfigureAudienceProfileFields();
|
||||
});
|
||||
batchSubmitButton.addEventListener("click", () => {
|
||||
void handlers.onSubmitBatch();
|
||||
});
|
||||
exportRangeSelect.addEventListener("change", () => {
|
||||
syncCustomPagesInputVisibility({
|
||||
batchSubmitButton,
|
||||
audienceProfileFieldButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
@@ -107,6 +167,9 @@ export function ensurePluginToolbar(
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
@@ -132,6 +195,15 @@ function appendOption(
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton: root.querySelector(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
) as HTMLButtonElement,
|
||||
audienceProfileExportButton: root.querySelector(
|
||||
'[data-plugin-export-audience-profile="button"]'
|
||||
) as HTMLButtonElement,
|
||||
audienceProfileFieldButton: root.querySelector(
|
||||
'[data-plugin-audience-profile-fields="button"]'
|
||||
) as HTMLButtonElement,
|
||||
batchSubmitButton: root.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement,
|
||||
@@ -214,6 +286,9 @@ export function setToolbarBusyState(
|
||||
): void {
|
||||
[
|
||||
toolbar.batchSubmitButton,
|
||||
toolbar.audienceProfileFieldButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
@@ -230,8 +305,8 @@ export function setToolbarExportStatus(
|
||||
}
|
||||
|
||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden =
|
||||
toolbar.exportRangeSelect.value !== "custom";
|
||||
toolbar.exportRangeSelect.hidden = true;
|
||||
toolbar.exportCustomPagesInput.hidden = true;
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
@@ -410,6 +485,9 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
||||
function applyNativeControlStyles(
|
||||
document: Document,
|
||||
controls: {
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
@@ -425,12 +503,19 @@ function applyNativeControlStyles(
|
||||
findNativeActionButton(document, "导出");
|
||||
|
||||
if (nativeButton) {
|
||||
controls.exportButton.className = nativeButton.className;
|
||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||
controls.batchSubmitButton.className = nativeButton.className;
|
||||
}
|
||||
|
||||
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
|
||||
applyPrimaryButtonStyles(button, Boolean(primaryButton));
|
||||
[
|
||||
controls.audienceProfileExportButton,
|
||||
controls.audienceProfileByIdExportButton,
|
||||
controls.audienceProfileFieldButton,
|
||||
controls.batchSubmitButton
|
||||
].forEach((button) => {
|
||||
applyPrimaryButtonStyles(button);
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
@@ -449,21 +534,18 @@ function applyNativeControlStyles(
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(
|
||||
button: HTMLButtonElement,
|
||||
isUsingNativePrimaryButtonClass: boolean
|
||||
button: HTMLButtonElement
|
||||
): void {
|
||||
if (!isUsingNativePrimaryButtonClass) {
|
||||
button.style.backgroundColor = "#fe346e";
|
||||
button.style.border = "1px solid #fe346e";
|
||||
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";
|
||||
} else {
|
||||
button.style.color = "#ffffff";
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -474,6 +556,55 @@ function applyStatusStyles(statusText: HTMLElement): void {
|
||||
statusText.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function ensurePluginActionButtonTheme(document: Document): void {
|
||||
if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
|
||||
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
||||
background-color: #6d1627 !important;
|
||||
border-color: #6d1627 !important;
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
|
||||
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
||||
background-color: #58111f !important;
|
||||
border-color: #58111f !important;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
||||
[data-plugin-audience-profile-fields="button"]:focus-visible,
|
||||
[data-plugin-batch-submit="button"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:disabled,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||
[data-plugin-audience-profile-fields="button"]:disabled,
|
||||
[data-plugin-batch-submit="button"]:disabled {
|
||||
background-color: #c89ca4 !important;
|
||||
border-color: #c89ca4 !important;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.authorName =
|
||||
mergeStringValue(existingRecord.authorName, row.authorName) ??
|
||||
existingRecord.authorName;
|
||||
existingRecord.coreUserId = mergeStringValue(
|
||||
existingRecord.coreUserId,
|
||||
row.coreUserId
|
||||
);
|
||||
existingRecord.location = mergeStringValue(
|
||||
existingRecord.location,
|
||||
row.location
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
readMarketListRequestSnapshot,
|
||||
type MarketListRequestSnapshot
|
||||
} from "./market-list-request-snapshot";
|
||||
import {
|
||||
parseMarketListResponse,
|
||||
readKnownPaginationNumber
|
||||
} from "./market-list-row";
|
||||
import type { MarketExportTarget, MarketRecord } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
interface SilentExportControllerOptions {
|
||||
document: Document;
|
||||
fetchImpl?: FetchLike;
|
||||
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||
}
|
||||
|
||||
type PageSource = "body" | "none" | "url";
|
||||
|
||||
const PAGE_NUMBER_KEYS = [
|
||||
"currentPage",
|
||||
"page",
|
||||
"pageNo",
|
||||
"pageNum",
|
||||
"page_no",
|
||||
"page_num"
|
||||
] as const;
|
||||
|
||||
export function createSilentExportController(
|
||||
options: SilentExportControllerOptions
|
||||
) {
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
|
||||
return {
|
||||
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[] | null> {
|
||||
const snapshot = readMarketListRequestSnapshot(options.document);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRequest = createPagedRequest(snapshot);
|
||||
if (!baseRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedRecords = new Map<string, MarketRecord>();
|
||||
const maxPageCount = target.mode === "count" ? target.pageCount : 200;
|
||||
let totalPagesHint: number | undefined;
|
||||
|
||||
for (let offset = 0; offset < maxPageCount; offset += 1) {
|
||||
const pageNumber = baseRequest.initialPage + offset;
|
||||
options.onProgress?.({
|
||||
currentPage: offset + 1,
|
||||
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
|
||||
});
|
||||
|
||||
const payload = await fetchPagePayload(fetchImpl, baseRequest, pageNumber);
|
||||
const parsedResponse = parseMarketListResponse(payload);
|
||||
if (!parsedResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
totalPagesHint = parsedResponse.totalPages ?? totalPagesHint;
|
||||
if (parsedResponse.records.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
parsedResponse.records.forEach((record) => {
|
||||
const existingRecord = mergedRecords.get(record.authorId);
|
||||
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
||||
});
|
||||
|
||||
if (target.mode === "count" && offset + 1 >= target.pageCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (target.mode === "all") {
|
||||
if (
|
||||
typeof parsedResponse.totalPages === "number" &&
|
||||
pageNumber >= parsedResponse.totalPages
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsedResponse.pageSize === "number" &&
|
||||
parsedResponse.records.length < parsedResponse.pageSize
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mergedRecords.values());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPagedRequest(
|
||||
snapshot: MarketListRequestSnapshot
|
||||
): {
|
||||
initialPage: number;
|
||||
pageSource: PageSource;
|
||||
snapshot: MarketListRequestSnapshot;
|
||||
} {
|
||||
const bodyPage = readPageFromBody(snapshot.body);
|
||||
if (bodyPage !== null) {
|
||||
return {
|
||||
initialPage: bodyPage,
|
||||
pageSource: "body",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
const urlPage = readPageFromUrl(snapshot.url);
|
||||
if (urlPage !== null) {
|
||||
return {
|
||||
initialPage: urlPage,
|
||||
pageSource: "url",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initialPage: 1,
|
||||
pageSource: "none",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPagePayload(
|
||||
fetchImpl: FetchLike,
|
||||
request: {
|
||||
pageSource: PageSource;
|
||||
snapshot: MarketListRequestSnapshot;
|
||||
},
|
||||
pageNumber: number
|
||||
): Promise<unknown> {
|
||||
const nextUrl =
|
||||
request.pageSource === "url"
|
||||
? mutateUrlPage(request.snapshot.url, pageNumber)
|
||||
: request.snapshot.url;
|
||||
const nextBody = mutateBodyPage(request.snapshot.body, pageNumber);
|
||||
|
||||
const response = await fetchImpl(nextUrl, {
|
||||
body: nextBody,
|
||||
credentials: "include",
|
||||
headers: filterReplayHeaders(request.snapshot.headers, nextBody),
|
||||
method: request.snapshot.method
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("静默导出请求失败");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function readPageFromUrl(url: string): number | null {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
const value = readNumericString(parsedUrl.searchParams.get(key));
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mutateUrlPage(url: string, pageNumber: number): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
if (!parsedUrl.searchParams.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set(key, String(pageNumber));
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set("page", String(pageNumber));
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function readPageFromBody(body: string | undefined): number | null {
|
||||
const parsedBody = parseBody(body);
|
||||
if (!parsedBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readKnownPaginationNumber(parsedBody, "page");
|
||||
}
|
||||
|
||||
function mutateBodyPage(body: string | undefined, pageNumber: number): string | undefined {
|
||||
if (!body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const trimmedBody = body.trim();
|
||||
if (!trimmedBody) {
|
||||
return body;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmedBody);
|
||||
if (!replacePageNumberInValue(parsedJson, pageNumber) && isRecord(parsedJson)) {
|
||||
parsedJson.page = pageNumber;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsedJson);
|
||||
} catch {
|
||||
const searchParams = new URLSearchParams(trimmedBody);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
if (!searchParams.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
searchParams.set(key, String(pageNumber));
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
searchParams.set("page", String(pageNumber));
|
||||
return searchParams.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function parseBody(body: string | undefined): Record<string, unknown> | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedBody = body.trim();
|
||||
if (!trimmedBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedBody = JSON.parse(trimmedBody);
|
||||
return isRecord(parsedBody) ? parsedBody : null;
|
||||
} catch {
|
||||
const searchParams = new URLSearchParams(trimmedBody);
|
||||
const payload: Record<string, unknown> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
payload[key] = value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
function replacePageNumberInValue(value: unknown, pageNumber: number): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let replaced = false;
|
||||
PAGE_NUMBER_KEYS.forEach((key) => {
|
||||
if (!(key in value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value[key] = pageNumber;
|
||||
replaced = true;
|
||||
});
|
||||
|
||||
if (replaced) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(value).some((entry) => replacePageNumberInValue(entry, pageNumber));
|
||||
}
|
||||
|
||||
function filterReplayHeaders(
|
||||
headers: Record<string, string> | undefined,
|
||||
body: string | undefined
|
||||
): HeadersInit | undefined {
|
||||
const filteredHeaders = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).filter(([key]) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
return normalizedKey !== "content-length" && normalizedKey !== "host";
|
||||
})
|
||||
);
|
||||
|
||||
if (body) {
|
||||
if (!hasHeader(filteredHeaders, "accept")) {
|
||||
filteredHeaders.Accept = "application/json, text/plain, */*";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "content-type")) {
|
||||
filteredHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "x-login-source")) {
|
||||
filteredHeaders["x-login-source"] = "1";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "agw-js-conv")) {
|
||||
filteredHeaders["Agw-Js-Conv"] = "str";
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(filteredHeaders).length > 0 ? filteredHeaders : undefined;
|
||||
}
|
||||
|
||||
function hasHeader(headers: Record<string, string>, key: string): boolean {
|
||||
return Object.keys(headers).some((headerKey) => headerKey.toLowerCase() === key);
|
||||
}
|
||||
|
||||
function readNumericString(value: string | null): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function mergeMarketRecord(
|
||||
existingRecord: MarketRecord | undefined,
|
||||
incomingRecord: MarketRecord
|
||||
): MarketRecord {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
...incomingRecord,
|
||||
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
|
||||
rates: mergeFieldMap(undefined, incomingRecord.rates),
|
||||
status: incomingRecord.status ?? "idle"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
incomingRecord.exportFields
|
||||
),
|
||||
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
|
||||
hasDirectRatesSource:
|
||||
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
|
||||
location: mergeStringValue(existingRecord.location, incomingRecord.location),
|
||||
price21To60s: mergeStringValue(
|
||||
existingRecord.price21To60s,
|
||||
incomingRecord.price21To60s
|
||||
),
|
||||
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
|
||||
status: incomingRecord.status ?? existingRecord.status
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
): T | undefined {
|
||||
if (!current && !incoming) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...(current ?? {})
|
||||
} as Record<string, string | undefined>;
|
||||
|
||||
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||
const currentValue = merged[key];
|
||||
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function mergeStringValue(
|
||||
current: string | undefined,
|
||||
incoming: string | undefined
|
||||
): string | undefined {
|
||||
return hasTextValue(incoming) ? incoming : current;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return Boolean(value && value.trim().length > 0);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface MarketRowSnapshot {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
backendMetrics?: BackendMetrics;
|
||||
coreUserId?: string;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource?: boolean;
|
||||
location?: string;
|
||||
|
||||
+12
-1
@@ -3,7 +3,14 @@
|
||||
"name": "Star Chart Search Enhancer",
|
||||
"version": "0.2.0421.2",
|
||||
"description": "Bootstraps the Xingtu creator market content script.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
|
||||
"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": [
|
||||
"http://*/*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
@@ -11,6 +18,10 @@
|
||||
"https://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "assets/icons/icon-16.png",
|
||||
"32": "assets/icons/icon-32.png"
|
||||
},
|
||||
"default_popup": "popup/index.html"
|
||||
},
|
||||
"background": {
|
||||
@@ -23,7 +34,7 @@
|
||||
"https://*.xingtu.cn/ad/creator/market*"
|
||||
],
|
||||
"js": ["content/index.js"],
|
||||
"run_at": "document_idle"
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
|
||||
@@ -4,6 +4,183 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Chart Search Enhancer</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--popup-bg: #f3f4f6;
|
||||
--popup-card: #ffffff;
|
||||
--popup-text: #111827;
|
||||
--popup-subtle: #6b7280;
|
||||
--popup-border: #d1d5db;
|
||||
--popup-accent: #8f1f4b;
|
||||
--popup-accent-strong: #74183b;
|
||||
--popup-success: #065f46;
|
||||
--popup-warning: #92400e;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 380px;
|
||||
min-height: 560px;
|
||||
margin: 0;
|
||||
background: var(--popup-bg);
|
||||
color: var(--popup-text);
|
||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.popup-shell {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 6px 2px 2px;
|
||||
}
|
||||
|
||||
.popup-eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--popup-subtle);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.popup-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.08;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.popup-card {
|
||||
background: var(--popup-card);
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.popup-card-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--popup-subtle);
|
||||
}
|
||||
|
||||
.popup-status {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-status--accent {
|
||||
color: var(--popup-accent);
|
||||
}
|
||||
|
||||
.popup-user {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.popup-copy,
|
||||
.popup-error,
|
||||
.popup-warning,
|
||||
.popup-success {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--popup-subtle);
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.popup-warning {
|
||||
color: var(--popup-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-success {
|
||||
color: var(--popup-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-notes {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 18px;
|
||||
color: var(--popup-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.popup-notes li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.popup-button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--popup-border);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.popup-button--primary {
|
||||
border-color: var(--popup-accent);
|
||||
background: var(--popup-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.popup-button--primary:hover {
|
||||
background: var(--popup-accent-strong);
|
||||
border-color: var(--popup-accent-strong);
|
||||
}
|
||||
|
||||
.popup-button--secondary {
|
||||
background: #fff;
|
||||
color: var(--popup-text);
|
||||
}
|
||||
|
||||
.popup-button--secondary:hover,
|
||||
.popup-button--ghost:hover {
|
||||
background: rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
|
||||
.popup-button--ghost {
|
||||
background: transparent;
|
||||
color: var(--popup-subtle);
|
||||
border-color: transparent;
|
||||
padding-inline: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
|
||||
+138
-5
@@ -2,6 +2,8 @@ import {
|
||||
renderDevPanel,
|
||||
renderLoggedIn,
|
||||
renderLoggedOut,
|
||||
renderUpdateStatus,
|
||||
setUpdateDownloadStatus,
|
||||
setProtectedApiResult
|
||||
} from "./view";
|
||||
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||
@@ -10,17 +12,27 @@ import {
|
||||
type AuthResponseMessage
|
||||
} from "../shared/auth-messages";
|
||||
import { createProtectedApiClient } from "../shared/protected-api-client";
|
||||
import {
|
||||
compareExtensionVersions,
|
||||
fetchUpdateManifest as fetchUpdateManifestFromUrl,
|
||||
type UpdateManifest
|
||||
} from "../shared/update-check";
|
||||
import { UPDATE_MANIFEST_URL } from "../shared/update-config";
|
||||
|
||||
interface BootPopupOptions {
|
||||
config?: Partial<AuthConfig>;
|
||||
currentVersion?: string;
|
||||
document?: Document;
|
||||
fetchProtectedApi?: () => Promise<unknown>;
|
||||
fetchUpdateManifest?: () => Promise<UpdateManifest>;
|
||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||
updateManifestUrl?: string;
|
||||
}
|
||||
|
||||
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||
const currentDocument = options.document ?? document;
|
||||
const popupConfig = readAuthConfig(options.config);
|
||||
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
||||
const root = currentDocument.querySelector("#app");
|
||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||
|
||||
@@ -48,15 +60,28 @@ export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
sendMessage
|
||||
}).loadProtectedMockData;
|
||||
const fetchUpdateManifest =
|
||||
options.fetchUpdateManifest ??
|
||||
(() =>
|
||||
fetchUpdateManifestFromUrl(
|
||||
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
|
||||
));
|
||||
|
||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
|
||||
currentVersion,
|
||||
fetchUpdateManifest
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCurrentAuthState(
|
||||
root: HTMLElement,
|
||||
popupConfig: AuthConfig,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
fetchProtectedApi: () => Promise<unknown>
|
||||
fetchProtectedApi: () => Promise<unknown>,
|
||||
updateOptions: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||
@@ -71,19 +96,22 @@ async function renderCurrentAuthState(
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, {
|
||||
actionMessage: { type: "auth:sign-in" },
|
||||
fetchProtectedApi
|
||||
fetchProtectedApi,
|
||||
updateOptions
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoggedIn(root, response.value);
|
||||
await runUpdateCheck(root, sendMessage, updateOptions);
|
||||
root
|
||||
.querySelector('[data-popup-sign-out="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, {
|
||||
actionMessage: { type: "auth:sign-out" },
|
||||
fetchProtectedApi
|
||||
fetchProtectedApi,
|
||||
updateOptions
|
||||
});
|
||||
});
|
||||
if (popupConfig.enableDevAuthPanel) {
|
||||
@@ -103,6 +131,10 @@ async function runAuthAction(
|
||||
options: {
|
||||
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||
fetchProtectedApi: () => Promise<unknown>;
|
||||
updateOptions: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await sendMessage(options.actionMessage);
|
||||
@@ -121,7 +153,8 @@ async function runAuthAction(
|
||||
root,
|
||||
popupConfig,
|
||||
sendMessage,
|
||||
options.fetchProtectedApi
|
||||
options.fetchProtectedApi,
|
||||
options.updateOptions
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +166,106 @@ function isActionError(response: unknown): response is Extract<AuthResponseMessa
|
||||
);
|
||||
}
|
||||
|
||||
async function runUpdateCheck(
|
||||
root: HTMLElement,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
options: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
}
|
||||
): Promise<void> {
|
||||
renderUpdateStatus(root, {
|
||||
currentVersion: options.currentVersion,
|
||||
status: "checking"
|
||||
});
|
||||
|
||||
try {
|
||||
const manifest = await options.fetchUpdateManifest();
|
||||
if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) {
|
||||
renderUpdateStatus(root, {
|
||||
currentVersion: options.currentVersion,
|
||||
status: "latest"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderUpdateStatus(root, {
|
||||
currentVersion: options.currentVersion,
|
||||
manifest,
|
||||
status: "available"
|
||||
});
|
||||
bindUpdateDownloadButtons(root, sendMessage, manifest);
|
||||
} catch (error) {
|
||||
renderUpdateStatus(root, {
|
||||
currentVersion: options.currentVersion,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
status: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindUpdateDownloadButtons(
|
||||
root: HTMLElement,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
manifest: UpdateManifest
|
||||
): void {
|
||||
root
|
||||
.querySelector('[data-popup-download-update="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void downloadUpdateAsset(root, sendMessage, {
|
||||
filename: "star-chart-search-enhancer-internal.zip",
|
||||
url: manifest.zipUrl
|
||||
});
|
||||
});
|
||||
|
||||
root
|
||||
.querySelector('[data-popup-download-guide="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void downloadUpdateAsset(root, sendMessage, {
|
||||
filename: "星图增强插件-超简单安装使用指南.pdf",
|
||||
url: manifest.guideUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadUpdateAsset(
|
||||
root: HTMLElement,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
options: {
|
||||
filename: string;
|
||||
url: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
setUpdateDownloadStatus(root, "正在下载...");
|
||||
try {
|
||||
await sendMessage({
|
||||
filename: options.filename,
|
||||
type: "update:download",
|
||||
url: options.url
|
||||
});
|
||||
setUpdateDownloadStatus(root, "已触发下载。下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。");
|
||||
} catch (error) {
|
||||
setUpdateDownloadStatus(
|
||||
root,
|
||||
error instanceof Error ? error.message : "下载失败,请稍后重试"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentVersion(): string {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
runtime?: {
|
||||
getManifest?: () => { version?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.runtime;
|
||||
|
||||
return runtime?.getManifest?.().version ?? "0.0.0";
|
||||
}
|
||||
|
||||
async function runProtectedApiProbe(
|
||||
root: HTMLElement,
|
||||
fetchProtectedApi: () => Promise<unknown>
|
||||
|
||||
+125
-16
@@ -1,12 +1,20 @@
|
||||
import type { AuthStateValue } from "../shared/auth-messages";
|
||||
import type { UpdateManifest } from "../shared/update-check";
|
||||
|
||||
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-out">
|
||||
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-out">
|
||||
<header class="popup-header" data-popup-header="root">
|
||||
<p class="popup-eyebrow">内部工具</p>
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>登录后才能使用星图增强功能</p>
|
||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||
</header>
|
||||
<section class="popup-card popup-card--account" data-popup-account="card">
|
||||
<div class="popup-card-title">登录状态</div>
|
||||
<p class="popup-status">未登录</p>
|
||||
<p class="popup-copy">登录后才能使用星图增强功能</p>
|
||||
${error ? `<p class="popup-error" data-popup-error="true">${escapeHtml(error)}</p>` : ""}
|
||||
<button type="button" class="popup-button popup-button--primary" data-popup-sign-in="button">登录 Logto</button>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -18,30 +26,131 @@ export function renderLoggedIn(
|
||||
const userInfo = authState.userInfo;
|
||||
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-in">
|
||||
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-in">
|
||||
<header class="popup-header" data-popup-header="root">
|
||||
<p class="popup-eyebrow">内部工具</p>
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>已登录</p>
|
||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||
<p>${userInfo?.email ?? ""}</p>
|
||||
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||
</header>
|
||||
<section class="popup-card popup-card--account" data-popup-account="card">
|
||||
<div class="popup-card-title">登录状态</div>
|
||||
<p class="popup-status">已登录</p>
|
||||
<p class="popup-user">${escapeHtml(userInfo?.name ?? userInfo?.username ?? "未知用户")}</p>
|
||||
<p class="popup-copy">${escapeHtml(userInfo?.email ?? "")}</p>
|
||||
</section>
|
||||
<section class="popup-card popup-card--update" data-popup-update="card" data-popup-update-root="root">
|
||||
<div class="popup-card-title">版本更新</div>
|
||||
<p data-popup-update-status="text" class="popup-status">正在检查更新...</p>
|
||||
</section>
|
||||
<footer class="popup-footer">
|
||||
<button type="button" class="popup-button popup-button--ghost" data-popup-sign-out="button">退出登录</button>
|
||||
</footer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderUpdateStatus(
|
||||
root: HTMLElement,
|
||||
options: {
|
||||
currentVersion: string;
|
||||
manifest?: UpdateManifest;
|
||||
message?: string | null;
|
||||
status: "checking" | "error" | "latest" | "available";
|
||||
}
|
||||
): void {
|
||||
const container = root.querySelector('[data-popup-update-root="root"]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.status === "checking") {
|
||||
container.innerHTML = `
|
||||
<div class="popup-card-title">版本更新</div>
|
||||
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||
<p class="popup-copy">正在检查更新...</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.status === "error") {
|
||||
container.innerHTML = `
|
||||
<div class="popup-card-title">版本更新</div>
|
||||
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||
<p class="popup-warning">暂时无法检查更新</p>
|
||||
${options.message ? `<p class="popup-error">${escapeHtml(options.message)}</p>` : ""}
|
||||
<p class="popup-copy">如果需要新版,请联系维护同事获取更新包。</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.status === "latest" || !options.manifest) {
|
||||
container.innerHTML = `
|
||||
<div class="popup-card-title">版本更新</div>
|
||||
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||
<p class="popup-success">当前已是最新版本</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="popup-card-title">版本更新</div>
|
||||
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||
<p class="popup-status popup-status--accent">发现新版本:${options.manifest.latestVersion}</p>
|
||||
${renderReleaseNotes(options.manifest.releaseNotes)}
|
||||
<div class="popup-actions">
|
||||
<button type="button" class="popup-button popup-button--primary" data-popup-download-update="button">下载更新包</button>
|
||||
<button type="button" class="popup-button popup-button--secondary" data-popup-download-guide="button">下载使用说明</button>
|
||||
</div>
|
||||
<p data-popup-update-download-status="text" class="popup-copy">下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。</p>
|
||||
`;
|
||||
}
|
||||
|
||||
export function setUpdateDownloadStatus(
|
||||
root: HTMLElement,
|
||||
value: string
|
||||
): void {
|
||||
const output = root.querySelector('[data-popup-update-download-status="text"]');
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.textContent = value;
|
||||
}
|
||||
|
||||
function renderReleaseNotes(releaseNotes: string[]): string {
|
||||
if (releaseNotes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `
|
||||
<ul class="popup-notes">
|
||||
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function renderDevPanel(
|
||||
root: HTMLElement,
|
||||
authState: AuthStateValue
|
||||
): void {
|
||||
const panel = root.ownerDocument.createElement("section");
|
||||
panel.className = "popup-card popup-card--dev";
|
||||
panel.dataset.popupDevPanel = "root";
|
||||
panel.innerHTML = `
|
||||
<h2>dev auth panel</h2>
|
||||
<p>resource: ${authState.resource ?? ""}</p>
|
||||
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
||||
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
||||
<p>error: ${authState.lastError ?? ""}</p>
|
||||
<button type="button" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||
<div class="popup-card-title">dev auth panel</div>
|
||||
<p class="popup-copy">resource: ${escapeHtml(authState.resource ?? "")}</p>
|
||||
<p class="popup-copy">scopes: ${escapeHtml((authState.scopes ?? []).join(", "))}</p>
|
||||
<p class="popup-copy">token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||
<p class="popup-copy">expires: ${escapeHtml(String(authState.accessTokenExpiresAt ?? "unknown"))}</p>
|
||||
<p class="popup-copy">error: ${escapeHtml(authState.lastError ?? "")}</p>
|
||||
<button type="button" class="popup-button popup-button--secondary" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||
<pre data-popup-protected-api-result="output"></pre>
|
||||
`;
|
||||
root.appendChild(panel);
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface AuthConfig {
|
||||
const defaultAuthConfig: AuthConfig = {
|
||||
apiResource: "https://talent-search.intelligrow.cn",
|
||||
appId: "i4jkllbvih0554r4n0fd3",
|
||||
enableDevAuthPanel: true,
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
||||
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.29:8083";
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface UpdateManifest {
|
||||
guideUrl: string;
|
||||
latestVersion: string;
|
||||
minSupportedVersion: string;
|
||||
publishedAt: string;
|
||||
releaseNotes: string[];
|
||||
zipUrl: string;
|
||||
}
|
||||
|
||||
export function compareExtensionVersions(left: string, right: string): number {
|
||||
const leftParts = parseVersionParts(left);
|
||||
const rightParts = parseVersionParts(right);
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftValue = leftParts[index] ?? 0;
|
||||
const rightValue = rightParts[index] ?? 0;
|
||||
if (leftValue !== rightValue) {
|
||||
return leftValue - rightValue;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseUpdateManifest(value: unknown): UpdateManifest | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<UpdateManifest>;
|
||||
if (
|
||||
!isVersionString(candidate.latestVersion) ||
|
||||
!isVersionString(candidate.minSupportedVersion) ||
|
||||
!isHttpsUrl(candidate.zipUrl) ||
|
||||
!isHttpsUrl(candidate.guideUrl) ||
|
||||
typeof candidate.publishedAt !== "string" ||
|
||||
!Array.isArray(candidate.releaseNotes) ||
|
||||
!candidate.releaseNotes.every((note) => typeof note === "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
guideUrl: candidate.guideUrl,
|
||||
latestVersion: candidate.latestVersion,
|
||||
minSupportedVersion: candidate.minSupportedVersion,
|
||||
publishedAt: candidate.publishedAt,
|
||||
releaseNotes: candidate.releaseNotes,
|
||||
zipUrl: candidate.zipUrl
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchUpdateManifest(
|
||||
manifestUrl: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<UpdateManifest> {
|
||||
const response = await fetchImpl(manifestUrl, {
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`update manifest request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const manifest = parseUpdateManifest(await response.json());
|
||||
if (!manifest) {
|
||||
throw new Error("update manifest is invalid");
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function parseVersionParts(value: string): number[] {
|
||||
return value.split(".").map((part) => {
|
||||
const parsed = Number.parseInt(part, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isVersionString(value: unknown): value is string {
|
||||
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UPDATE_MANIFEST_URL =
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AUDIENCE_PROFILE_TARGETS,
|
||||
createAudienceProfileClient,
|
||||
mapAudienceProfileResponse
|
||||
} from "../src/content/market/audience-profile-client";
|
||||
|
||||
describe("audience-profile-client", () => {
|
||||
test("loads connection user audience distributions from Xingtu", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => buildAudiencePayload(),
|
||||
ok: true
|
||||
}));
|
||||
const client = createAudienceProfileClient({
|
||||
baseUrl: "https://www.xingtu.cn",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
const result = await client.loadAudienceProfile({
|
||||
authorId: "7294473194298146854",
|
||||
authorName: "奇奇de海洋",
|
||||
status: "success"
|
||||
}, AUDIENCE_PROFILE_TARGETS.audience);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=5",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
gender: [
|
||||
{ label: "男性", value: "71.7%" },
|
||||
{ label: "女性", value: "28.3%" }
|
||||
],
|
||||
cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("loads fans and iron fan profiles from Xingtu fan distribution endpoint", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => buildAudiencePayload(),
|
||||
ok: true
|
||||
}));
|
||||
const client = createAudienceProfileClient({
|
||||
baseUrl: "https://www.xingtu.cn",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
await client.loadAudienceProfile({
|
||||
authorId: "7294473194298146854",
|
||||
authorName: "奇奇de海洋",
|
||||
status: "success"
|
||||
}, AUDIENCE_PROFILE_TARGETS.longtimeFans);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_fans_distribution?o_author_id=7294473194298146854&platform_source=1&author_type=5",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("maps Xingtu audience distribution payload into named profile sections", () => {
|
||||
const result = mapAudienceProfileResponse(buildAudiencePayload());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
age: [
|
||||
{ label: "18-23", value: "20%" },
|
||||
{ label: "24-30", value: "30%" },
|
||||
{ label: "31-40", value: "50%" }
|
||||
],
|
||||
province: [
|
||||
{ label: "广东", value: "60%" },
|
||||
{ label: "浙江", value: "40%" }
|
||||
],
|
||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||
interest: [{ label: "随拍", value: "100%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "100%" }]
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildAudiencePayload() {
|
||||
return {
|
||||
base_resp: {
|
||||
status_code: 0,
|
||||
status_message: ""
|
||||
},
|
||||
distributions: [
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "male", distribution_value: 717 },
|
||||
{ distribution_key: "female", distribution_value: 283 }
|
||||
],
|
||||
type_display: "性别分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "31-40", distribution_value: 50 },
|
||||
{ distribution_key: "18-23", distribution_value: 20 },
|
||||
{ distribution_key: "24-30", distribution_value: 30 }
|
||||
],
|
||||
type_display: "年龄分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "浙江", distribution_value: 40 },
|
||||
{ distribution_key: "广东", distribution_value: 60 }
|
||||
],
|
||||
type_display: "省份分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "广州", distribution_value: 304 },
|
||||
{ distribution_key: "北京", distribution_value: 291 },
|
||||
{ distribution_key: "上海", distribution_value: 405 }
|
||||
],
|
||||
type_display: "城市分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "一线", distribution_value: 1 }],
|
||||
type_display: "城市等级分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }],
|
||||
type_display: "兴趣分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }],
|
||||
type_display: "八大人群分布"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildAudienceProfileCsv,
|
||||
listAudienceProfileSelectableFieldGroups,
|
||||
listAudienceProfileCsvHeaders
|
||||
} from "../src/content/market/audience-profile-csv";
|
||||
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
|
||||
|
||||
describe("audience-profile-csv", () => {
|
||||
test("exports only requested profile distribution columns", () => {
|
||||
const csv = buildAudienceProfileCsv([
|
||||
{
|
||||
profiles: {
|
||||
audience: {
|
||||
age: [{ label: "31-40", value: "50%" }],
|
||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||
gender: [
|
||||
{ label: "男性", value: "71.7%" },
|
||||
{ label: "女性", value: "28.3%" }
|
||||
],
|
||||
status: "success"
|
||||
},
|
||||
fans: {
|
||||
age: [{ label: "31-40", value: "40%" }],
|
||||
cityTier: [{ label: "一线城市", value: "80%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "60%" }],
|
||||
gender: [
|
||||
{ label: "男性", value: "60%" },
|
||||
{ label: "女性", value: "40%" }
|
||||
],
|
||||
status: "success"
|
||||
},
|
||||
longtimeFans: {
|
||||
age: [{ label: "31-40", value: "30%" }],
|
||||
cityTier: [{ label: "一线城市", value: "70%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "50%" }],
|
||||
status: "success"
|
||||
}
|
||||
},
|
||||
businessAbility: {
|
||||
estimates: {
|
||||
oneToTwenty: {
|
||||
expectedCpe: "2.1",
|
||||
expectedCpm: "120.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "100%"
|
||||
},
|
||||
twentyToSixty: {
|
||||
expectedCpe: "3.7",
|
||||
expectedCpm: "212.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "缺失"
|
||||
}
|
||||
},
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: {
|
||||
averageComment: "4.5w",
|
||||
averageDuration: "150s",
|
||||
averageLike: "113.2w",
|
||||
averageShare: "26.5w",
|
||||
finishRate: "15.8%",
|
||||
interactionRate: "3.9%",
|
||||
medianPlay: "3738.4w",
|
||||
publishedItems: "<5"
|
||||
},
|
||||
xingtuVideo: {
|
||||
averageComment: "5.1w",
|
||||
averageDuration: "170s",
|
||||
averageLike: "150.3w",
|
||||
averageShare: "68.4w",
|
||||
finishRate: "19.9%",
|
||||
interactionRate: "5.5%",
|
||||
medianPlay: "4059.7w",
|
||||
publishedItems: "<5"
|
||||
}
|
||||
}
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
exportFields: {
|
||||
达人信息: "达人 A",
|
||||
连接用户数: "300w"
|
||||
},
|
||||
status: "success"
|
||||
}
|
||||
} satisfies AudienceProfileExportRow
|
||||
]);
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toContain("达人信息,连接用户数");
|
||||
expect(headerLine).not.toContain("抓取状态");
|
||||
expect(headerLine).not.toContain("失败原因");
|
||||
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
|
||||
expect(headerLine).toContain("内容数据-星图视频-平均转发");
|
||||
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
|
||||
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
|
||||
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
|
||||
expect(headerLine).not.toContain("商业能力-个人视频-播放量中位数");
|
||||
expect(headerLine).not.toContain("商业能力-20-60s视频-预期CPM");
|
||||
expect(headerLine).toContain("观众画像-男性占比");
|
||||
expect(headerLine).toContain("粉丝画像-女性占比");
|
||||
expect(headerLine).not.toContain("铁粉画像-男性占比");
|
||||
expect(headerLine).toContain("观众画像-31-40占比");
|
||||
expect(headerLine).toContain("粉丝画像-一线城市占比");
|
||||
expect(headerLine).toContain("铁粉画像-都市蓝领占比");
|
||||
expect(headerLine).not.toContain("观众画像-新一线城市占比");
|
||||
expect(headerLine).not.toContain("粉丝画像-新一线城市占比");
|
||||
expect(headerLine).not.toContain("铁粉画像-新一线城市占比");
|
||||
expect(headerLine).not.toContain("省份");
|
||||
expect(headerLine).not.toContain("地域TOP");
|
||||
expect(headerLine).not.toContain("兴趣TOP");
|
||||
expect(rowLine).toContain("71.7%");
|
||||
expect(rowLine).toContain("60%");
|
||||
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
|
||||
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
|
||||
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
|
||||
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
|
||||
});
|
||||
|
||||
test("leaves distribution cells empty when profile loading fails", () => {
|
||||
const csv = buildAudienceProfileCsv([
|
||||
{
|
||||
profiles: {
|
||||
audience: {
|
||||
failureReason: "request-failed",
|
||||
status: "failed"
|
||||
},
|
||||
fans: {
|
||||
failureReason: "timeout",
|
||||
status: "failed"
|
||||
},
|
||||
longtimeFans: {
|
||||
status: "failed"
|
||||
}
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
status: "success"
|
||||
}
|
||||
} satisfies AudienceProfileExportRow
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
|
||||
expect(rowLine).not.toContain("失败");
|
||||
expect(rowLine).not.toContain("request-failed");
|
||||
expect(rowLine).not.toContain("timeout");
|
||||
});
|
||||
|
||||
test("fills missing fixed distribution buckets with zero for successful profiles", () => {
|
||||
const csv = buildAudienceProfileCsv([
|
||||
{
|
||||
profiles: {
|
||||
audience: { status: "success" },
|
||||
fans: { status: "success" },
|
||||
longtimeFans: {
|
||||
age: [
|
||||
{ label: "18-23", value: "11.1%" },
|
||||
{ label: "24-30", value: "33.3%" },
|
||||
{ label: "31-40", value: "55.6%" }
|
||||
],
|
||||
cityTier: [
|
||||
{ label: "一线城市", value: "10%" },
|
||||
{ label: "二线城市", value: "20%" },
|
||||
{ label: "三线城市", value: "40%" },
|
||||
{ label: "四线城市", value: "30%" }
|
||||
],
|
||||
crowd: [
|
||||
{ label: "精致妈妈", value: "30%" },
|
||||
{ label: "新锐白领", value: "20%" },
|
||||
{ label: "资深中产", value: "10%" },
|
||||
{ label: "都市蓝领", value: "20%" },
|
||||
{ label: "小镇中老年", value: "10%" },
|
||||
{ label: "小镇青年", value: "10%" }
|
||||
],
|
||||
status: "success"
|
||||
}
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
status: "success"
|
||||
}
|
||||
} satisfies AudienceProfileExportRow
|
||||
]);
|
||||
|
||||
expect(readCsvValue(csv, "铁粉画像-41-50占比")).toBe("0%");
|
||||
expect(readCsvValue(csv, "铁粉画像-50+占比")).toBe("0%");
|
||||
expect(readCsvValue(csv, "铁粉画像-五线城市占比")).toBe("0%");
|
||||
expect(readCsvValue(csv, "铁粉画像-都市银发占比")).toBe("0%");
|
||||
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
|
||||
expect(csv.split("\n")[0]).not.toContain("新一线城市占比");
|
||||
});
|
||||
|
||||
test("filters export columns by selected headers", () => {
|
||||
const row = buildSuccessRow();
|
||||
const csv = buildAudienceProfileCsv([row], {
|
||||
selectedHeaders: [
|
||||
"内容数据-个人视频-播放量中位数",
|
||||
"观众画像-男性占比"
|
||||
]
|
||||
});
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toBe(
|
||||
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
|
||||
);
|
||||
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
|
||||
expect(headerLine).not.toContain("秒思api-看后搜数");
|
||||
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
||||
});
|
||||
|
||||
test("always keeps fixed id export headers when filtering", () => {
|
||||
const row = buildSuccessRow({
|
||||
exportFields: {
|
||||
达人ID: "123",
|
||||
达人名称: "达人 A",
|
||||
导出状态: "成功",
|
||||
失败原因: ""
|
||||
}
|
||||
});
|
||||
const csv = buildAudienceProfileCsv([row], {
|
||||
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
|
||||
});
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toBe(
|
||||
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
|
||||
);
|
||||
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
|
||||
});
|
||||
|
||||
test("lists headers for field picker defaults", () => {
|
||||
expect(listAudienceProfileCsvHeaders([buildSuccessRow()])).toEqual(
|
||||
expect.arrayContaining([
|
||||
"达人信息",
|
||||
"连接用户数",
|
||||
"秒思api-看后搜数",
|
||||
"内容数据-个人视频-播放量中位数",
|
||||
"效果预估-20-60s视频-预期CPM",
|
||||
"观众画像-男性占比",
|
||||
"铁粉画像-小镇青年占比"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("groups selectable profile export fields", () => {
|
||||
expect(listAudienceProfileSelectableFieldGroups()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
headers: expect.arrayContaining(["秒思api-看后搜数"]),
|
||||
label: "秒思api数据"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
|
||||
label: "内容数据"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.arrayContaining(["效果预估-20-60s视频-预期CPM"]),
|
||||
label: "效果预估"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.arrayContaining(["观众画像-男性占比"]),
|
||||
label: "观众画像"
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function readCsvValue(csv: string, header: string): string {
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
const headers = headerLine.split(",");
|
||||
const values = rowLine.split(",");
|
||||
const index = headers.indexOf(header);
|
||||
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
return values[index] ?? "";
|
||||
}
|
||||
|
||||
function buildSuccessRow(
|
||||
overrides: Partial<AudienceProfileExportRow["record"]> = {}
|
||||
): AudienceProfileExportRow {
|
||||
return {
|
||||
profiles: {
|
||||
audience: {
|
||||
age: [{ label: "31-40", value: "50%" }],
|
||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||
gender: [{ label: "男性", value: "71.7%" }],
|
||||
status: "success"
|
||||
},
|
||||
fans: { status: "success" },
|
||||
longtimeFans: { status: "success" }
|
||||
},
|
||||
businessAbility: {
|
||||
estimates: {
|
||||
twentyToSixty: {
|
||||
expectedCpe: "3.7",
|
||||
expectedCpm: "212.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "缺失"
|
||||
}
|
||||
},
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: {
|
||||
medianPlay: "3738.4w"
|
||||
}
|
||||
}
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
exportFields: {
|
||||
达人信息: "达人 A",
|
||||
连接用户数: "300w"
|
||||
},
|
||||
status: "success",
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe("auth-config", () => {
|
||||
expect(readAuthConfig()).toEqual({
|
||||
apiResource: "https://talent-search.intelligrow.cn",
|
||||
appId: "i4jkllbvih0554r4n0fd3",
|
||||
enableDevAuthPanel: true,
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
||||
scopes: [
|
||||
"openid",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildAuthorBaseInfoUrl,
|
||||
createAuthorBaseClient,
|
||||
mapAuthorBaseInfoResponse
|
||||
} from "../src/content/market/author-base-client";
|
||||
|
||||
describe("author-base-client", () => {
|
||||
test("builds Xingtu author base info url", () => {
|
||||
expect(
|
||||
buildAuthorBaseInfoUrl("6866044569306267651", "https://www.xingtu.cn")
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps author nickname into a market record", () => {
|
||||
expect(mapAuthorBaseInfoResponse("6866044569306267651", {
|
||||
base_resp: { status_code: 0, status_message: "Success" },
|
||||
nick_name: "小九儿"
|
||||
})).toEqual({
|
||||
authorId: "6866044569306267651",
|
||||
authorName: "小九儿",
|
||||
status: "success"
|
||||
});
|
||||
});
|
||||
|
||||
test("loads author base info from Xingtu", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
base_resp: { status_code: 0, status_message: "Success" },
|
||||
nick_name: "小九儿"
|
||||
}),
|
||||
ok: true
|
||||
}));
|
||||
const client = createAuthorBaseClient({
|
||||
baseUrl: "https://www.xingtu.cn",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
await expect(client.loadAuthorBaseInfo("6866044569306267651")).resolves.toEqual({
|
||||
authorId: "6866044569306267651",
|
||||
authorName: "小九儿",
|
||||
status: "success"
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseAuthorIds } from "../src/content/market/author-id-input";
|
||||
|
||||
describe("author-id-input", () => {
|
||||
test("parses newline comma and space separated Xingtu author ids", () => {
|
||||
expect(parseAuthorIds(`
|
||||
6866044569306267651
|
||||
7040323176106033165,7088592143119286285
|
||||
7222310247979810854
|
||||
`)).toEqual({
|
||||
duplicates: [],
|
||||
ids: [
|
||||
"6866044569306267651",
|
||||
"7040323176106033165",
|
||||
"7088592143119286285",
|
||||
"7222310247979810854"
|
||||
],
|
||||
invalidTokens: []
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates ids and reports invalid tokens", () => {
|
||||
expect(parseAuthorIds(`
|
||||
6866044569306267651
|
||||
bad-id
|
||||
123
|
||||
6866044569306267651
|
||||
`)).toEqual({
|
||||
duplicates: ["6866044569306267651"],
|
||||
ids: ["6866044569306267651"],
|
||||
invalidTokens: ["bad-id", "123"]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns unauthenticated state when the access token cannot be read", async () => {
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(async () => {
|
||||
throw new Error("token expired");
|
||||
}),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => true),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect(controller.getAuthState()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthenticated: false,
|
||||
lastError: "token expired"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("delegates sign in to the auth client", async () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const controller = createAuthController({
|
||||
|
||||
@@ -48,6 +48,50 @@ describe("background-index", () => {
|
||||
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
test("downloads extension update assets", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const download = vi.fn(async () => undefined);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler({
|
||||
downloads: {
|
||||
download
|
||||
},
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = listeners[0](
|
||||
{
|
||||
filename: "star-chart-search-enhancer-internal.zip",
|
||||
type: "update:download",
|
||||
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
|
||||
},
|
||||
{},
|
||||
sendResponse
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(download).toHaveBeenCalledWith({
|
||||
filename: "star-chart-search-enhancer-internal.zip",
|
||||
saveAs: true,
|
||||
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
|
||||
});
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "update:download-ack"
|
||||
});
|
||||
});
|
||||
|
||||
test("responds to auth:get-state with auth status", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
@@ -164,7 +208,6 @@ describe("background-index", () => {
|
||||
{
|
||||
payload: {
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
@@ -182,9 +225,10 @@ describe("background-index", () => {
|
||||
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z"
|
||||
batchName: "批次A"
|
||||
})
|
||||
);
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "batch:ack",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { createBatchPayload } from "../src/content/market/batch-payload";
|
||||
|
||||
describe("batch-payload", () => {
|
||||
test("builds a batch id from the user id and timestamp", () => {
|
||||
test("builds the batch payload without a client-side batch id", () => {
|
||||
const payload = createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
@@ -16,17 +16,21 @@ describe("batch-payload", () => {
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [
|
||||
{ authorId: "111", authorName: "达人A", status: "success" },
|
||||
{
|
||||
authorId: "111",
|
||||
authorName: "达人A",
|
||||
coreUserId: "core-111",
|
||||
status: "success"
|
||||
},
|
||||
{ authorId: "222", authorName: "达人B", status: "success" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
authors: [
|
||||
{ authorId: "111", authorName: "达人A" },
|
||||
{ authorId: "111", authorName: "达人A", authorUid: "core-111" },
|
||||
{ authorId: "222", authorName: "达人B" }
|
||||
],
|
||||
batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||
|
||||
describe("batch-submit-client", () => {
|
||||
test("exports the default batch submit base url", () => {
|
||||
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.29:8083");
|
||||
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
|
||||
});
|
||||
|
||||
test("posts the batch payload with a Bearer token", async () => {
|
||||
@@ -36,7 +36,6 @@ describe("batch-submit-client", () => {
|
||||
|
||||
await client.submitBatch({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
@@ -49,7 +48,6 @@ describe("batch-submit-client", () => {
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
@@ -87,7 +85,6 @@ describe("batch-submit-client", () => {
|
||||
await expect(
|
||||
client.submitBatch({
|
||||
authors: [],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
@@ -115,7 +112,6 @@ describe("batch-submit-client", () => {
|
||||
await expect(
|
||||
client.submitBatch({
|
||||
authors: [],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveExtensionBuildDir } from "../scripts/build-output-path.mjs";
|
||||
|
||||
describe("build-output-path", () => {
|
||||
test("uses dist for release builds", () => {
|
||||
expect(resolveExtensionBuildDir("/repo", "release")).toBe(
|
||||
path.join("/repo", "dist")
|
||||
);
|
||||
});
|
||||
|
||||
test("uses dist for development builds", () => {
|
||||
expect(resolveExtensionBuildDir("/repo", "development")).toBe(
|
||||
path.join("/repo", "dist")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildBusinessAbilityEstimateUrl,
|
||||
buildBusinessAbilityVideoUrl,
|
||||
createBusinessAbilityClient,
|
||||
mapBusinessAbilityEstimateResponse,
|
||||
mapBusinessAbilityVideoResponse
|
||||
} from "../src/content/market/business-ability-client";
|
||||
|
||||
describe("business-ability-client", () => {
|
||||
test("builds commercial ability urls used by the Xingtu detail page", () => {
|
||||
expect(
|
||||
buildBusinessAbilityVideoUrl(
|
||||
"6724241209444794382",
|
||||
"https://www.xingtu.cn",
|
||||
2
|
||||
)
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
|
||||
);
|
||||
|
||||
expect(
|
||||
buildBusinessAbilityEstimateUrl(
|
||||
"6724241209444794382",
|
||||
"https://www.xingtu.cn"
|
||||
)
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps video content metrics into page-style display values", () => {
|
||||
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
|
||||
averageComment: "5.1w",
|
||||
averageDuration: "170s",
|
||||
averageLike: "150.3w",
|
||||
averageShare: "68.4w",
|
||||
finishRate: "19.9%",
|
||||
interactionRate: "5.5%",
|
||||
medianPlay: "4059.7w",
|
||||
publishedItems: "<5"
|
||||
});
|
||||
});
|
||||
|
||||
test("maps duration estimates into page-style display values", () => {
|
||||
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
|
||||
oneToTwenty: {
|
||||
expectedCpe: "2.1",
|
||||
expectedCpm: "120.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "100%"
|
||||
},
|
||||
overSixty: {
|
||||
expectedCpe: "4.2",
|
||||
expectedCpm: "240.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "100%"
|
||||
},
|
||||
twentyToSixty: {
|
||||
expectedCpe: "3.7",
|
||||
expectedCpm: "212.0",
|
||||
expectedPlay: "250w",
|
||||
hotRate: "100%"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps decimal CPM values and marks missing hot rate", () => {
|
||||
expect(mapBusinessAbilityEstimateResponse({
|
||||
base_resp: { status_code: 0, status_message: "" },
|
||||
cpe_1_20: "1.6347",
|
||||
cpe_20_60: "2.002",
|
||||
cpe_60: "2.104",
|
||||
cpm_1_20: "21.7955",
|
||||
cpm_20_60: "27.5628",
|
||||
cpm_60: "29.877",
|
||||
vv: "1010234"
|
||||
})).toEqual({
|
||||
oneToTwenty: {
|
||||
expectedCpe: "1.6",
|
||||
expectedCpm: "21.8",
|
||||
expectedPlay: "101w",
|
||||
hotRate: "缺失"
|
||||
},
|
||||
overSixty: {
|
||||
expectedCpe: "2.1",
|
||||
expectedCpm: "29.9",
|
||||
expectedPlay: "101w",
|
||||
hotRate: "缺失"
|
||||
},
|
||||
twentyToSixty: {
|
||||
expectedCpe: "2",
|
||||
expectedCpm: "27.6",
|
||||
expectedPlay: "101w",
|
||||
hotRate: "缺失"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (input: string) => {
|
||||
requestedUrls.push(input);
|
||||
return {
|
||||
json: async () =>
|
||||
input.includes("get_author_commerce_spread_info")
|
||||
? buildEstimatePayload()
|
||||
: buildVideoPayload(),
|
||||
ok: true
|
||||
};
|
||||
});
|
||||
const client = createBusinessAbilityClient({
|
||||
baseUrl: "https://www.xingtu.cn",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadBusinessAbility({
|
||||
authorId: "6724241209444794382",
|
||||
authorName: "李蠕蠕",
|
||||
status: "success"
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
estimates: expect.objectContaining({
|
||||
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
|
||||
}),
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
|
||||
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestedUrls).toEqual([
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
|
||||
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function buildVideoPayload() {
|
||||
return {
|
||||
avg_duration: 17002,
|
||||
base_resp: { status_code: 0, status_message: "" },
|
||||
comment_avg: 51404,
|
||||
interact_rate: { value: 551 },
|
||||
item_num: 2,
|
||||
like_avg: 1503028,
|
||||
play_mid: 40596960,
|
||||
play_over_rate: { value: 1991 },
|
||||
share_avg: 684318
|
||||
};
|
||||
}
|
||||
|
||||
function buildEstimatePayload() {
|
||||
return {
|
||||
base_resp: { status_code: 0, status_message: "" },
|
||||
cpe_1_20: "2.1035",
|
||||
cpe_20_60: "3.7161",
|
||||
cpe_60: "4.2069",
|
||||
cpm_1_20: "119.9976",
|
||||
cpm_20_60: "211.9958",
|
||||
cpm_60: "239.9953",
|
||||
platform_hot_rate: "1",
|
||||
vv: "2500049"
|
||||
};
|
||||
}
|
||||
+21
-21
@@ -14,14 +14,14 @@ describe("csv-exporter", () => {
|
||||
"达人名称",
|
||||
"地区",
|
||||
"21-60s报价",
|
||||
"单视频看后搜率",
|
||||
"商单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"看后搜率",
|
||||
"看后搜数",
|
||||
"新增A3数",
|
||||
"新增A3率",
|
||||
"CPA3",
|
||||
"cp_search"
|
||||
"秒思api-看后搜率",
|
||||
"秒思api-看后搜数",
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
].join(",")
|
||||
);
|
||||
});
|
||||
@@ -59,14 +59,14 @@ describe("csv-exporter", () => {
|
||||
"达人信息",
|
||||
"粉丝数",
|
||||
"21-60s报价",
|
||||
"单视频看后搜率",
|
||||
"商单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"看后搜率",
|
||||
"看后搜数",
|
||||
"新增A3数",
|
||||
"新增A3率",
|
||||
"CPA3",
|
||||
"cp_search"
|
||||
"秒思api-看后搜率",
|
||||
"秒思api-看后搜数",
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe(
|
||||
@@ -94,14 +94,14 @@ describe("csv-exporter", () => {
|
||||
[
|
||||
"达人信息",
|
||||
"粉丝数",
|
||||
"单视频看后搜率",
|
||||
"商单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"看后搜率",
|
||||
"看后搜数",
|
||||
"新增A3数",
|
||||
"新增A3率",
|
||||
"CPA3",
|
||||
"cp_search"
|
||||
"秒思api-看后搜率",
|
||||
"秒思api-看后搜数",
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import manifest from "../src/manifest.json";
|
||||
import { createManifest } from "../scripts/manifest.mjs";
|
||||
|
||||
describe("manifest", () => {
|
||||
test("injects the content script on the www Xingtu market page", () => {
|
||||
@@ -9,12 +10,16 @@ describe("manifest", () => {
|
||||
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
|
||||
])
|
||||
);
|
||||
expect(manifest.content_scripts?.[0]?.run_at).toBe("document_start");
|
||||
});
|
||||
|
||||
test("declares the downloads and auth permissions plus background worker", () => {
|
||||
expect(manifest.permissions).toEqual(
|
||||
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.arrayContaining([
|
||||
"http://*/*",
|
||||
@@ -26,4 +31,55 @@ describe("manifest", () => {
|
||||
expect(manifest.background?.service_worker).toBe("background/index.js");
|
||||
expect(manifest.action?.default_popup).toBe("popup/index.html");
|
||||
});
|
||||
|
||||
test("builds a release manifest with narrowed host permissions", () => {
|
||||
const releaseManifest = createManifest({ target: "release" });
|
||||
|
||||
expect(releaseManifest.permissions).toEqual(
|
||||
expect.arrayContaining(["downloads", "identity", "storage"])
|
||||
);
|
||||
expect(releaseManifest.host_permissions).toEqual([
|
||||
"https://xingtu.cn/ad/creator/market*",
|
||||
"https://*.xingtu.cn/ad/creator/market*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"https://talent-search.intelligrow.cn/*",
|
||||
"http://192.168.31.21:8083/*",
|
||||
"https://*/*"
|
||||
]);
|
||||
expect(releaseManifest.host_permissions).not.toEqual(
|
||||
expect.arrayContaining(["http://*/*", "http://127.0.0.1:4319/*"])
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a release manifest with extension icons", () => {
|
||||
const releaseManifest = createManifest({ target: "release" });
|
||||
|
||||
expect(releaseManifest.icons).toEqual({
|
||||
"16": "assets/icons/icon-16.png",
|
||||
"32": "assets/icons/icon-32.png",
|
||||
"48": "assets/icons/icon-48.png",
|
||||
"128": "assets/icons/icon-128.png"
|
||||
});
|
||||
expect(releaseManifest.key).toBe(manifest.key);
|
||||
expect(releaseManifest.action?.default_icon).toEqual({
|
||||
"16": "assets/icons/icon-16.png",
|
||||
"32": "assets/icons/icon-32.png"
|
||||
});
|
||||
});
|
||||
|
||||
test("uses EXTENSION_VERSION for release builds", () => {
|
||||
const previousVersion = process.env.EXTENSION_VERSION;
|
||||
process.env.EXTENSION_VERSION = "v0.0525.1";
|
||||
|
||||
try {
|
||||
const releaseManifest = createManifest({ target: "release" });
|
||||
expect(releaseManifest.version).toBe("0.0525.1");
|
||||
} finally {
|
||||
if (previousVersion === undefined) {
|
||||
delete process.env.EXTENSION_VERSION;
|
||||
} else {
|
||||
process.env.EXTENSION_VERSION = previousVersion;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
|
||||
expect(createMarketController).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("请先登录插件");
|
||||
});
|
||||
|
||||
test("shows an expired login message when auth state reports a token error", async () => {
|
||||
document.body.innerHTML = "<div></div>";
|
||||
|
||||
await bootContentScript({
|
||||
createMarketController: vi.fn(),
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: false,
|
||||
lastError: "token expired"
|
||||
}
|
||||
})),
|
||||
window
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("登录已过期,请重新登录");
|
||||
});
|
||||
});
|
||||
|
||||
+1347
-37
File diff suppressed because it is too large
Load Diff
@@ -181,7 +181,7 @@ describe("market-dom-sync", () => {
|
||||
expect(readSelectionHeaderText()).toBe("全选");
|
||||
expect(readSelectionRowCheckboxCount()).toBe(2);
|
||||
expect(readPluginHeaderTexts()).toEqual([
|
||||
"单视频看后搜率",
|
||||
"商单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
"看后搜率",
|
||||
"看后搜数",
|
||||
@@ -307,6 +307,52 @@ describe("market-dom-sync", () => {
|
||||
expect(readAuthorNames()).toEqual(["达人 A", "达人 B"]);
|
||||
});
|
||||
|
||||
test("inserts the selection column before the native author column", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixture();
|
||||
|
||||
const table = syncMarketTable(document);
|
||||
if (!table) {
|
||||
throw new Error("Expected market table");
|
||||
}
|
||||
|
||||
expect(readAuthorSectionColumnKeys()).toEqual(["selection", "author"]);
|
||||
});
|
||||
|
||||
test("keeps the selection cells aligned to the native author row heights", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixture();
|
||||
|
||||
const table = syncMarketTable(document);
|
||||
if (!table) {
|
||||
throw new Error("Expected market table");
|
||||
}
|
||||
|
||||
expect(readSelectionCellHeights()).toEqual(["120px", "120px"]);
|
||||
});
|
||||
|
||||
test("resyncs selection cell heights when native author row heights change", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixture();
|
||||
|
||||
expect(syncMarketTable(document)).not.toBeNull();
|
||||
expect(readSelectionCellHeights()).toEqual(["120px", "120px"]);
|
||||
|
||||
const authorCells = Array.from(
|
||||
document.querySelectorAll('[data-testid="author-section"] .content-column:not([data-market-column-group="selection"]) > .content-cell')
|
||||
) as HTMLElement[];
|
||||
const middleCells = Array.from(
|
||||
document.querySelectorAll('.middle-columns .content-column > .content-cell')
|
||||
) as HTMLElement[];
|
||||
const rightCells = Array.from(
|
||||
document.querySelectorAll('[data-testid="right-section"] .content-column > .content-cell')
|
||||
) as HTMLElement[];
|
||||
|
||||
[...authorCells, ...middleCells, ...rightCells].forEach((cell) => {
|
||||
cell.style.height = "152px";
|
||||
});
|
||||
|
||||
expect(syncMarketTable(document)).not.toBeNull();
|
||||
expect(readSelectionCellHeights()).toEqual(["152px", "152px"]);
|
||||
});
|
||||
|
||||
test("uses native-like alignment styles for plugin cells", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
|
||||
|
||||
@@ -962,14 +1008,37 @@ function readSelectionRowCheckboxCount() {
|
||||
}
|
||||
|
||||
function readAuthorNames() {
|
||||
const authorColumn = document.querySelector(
|
||||
'[data-testid="author-section"] .content-column'
|
||||
);
|
||||
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(
|
||||
document.querySelectorAll('[data-testid="author-section"] > .content-column'),
|
||||
(column) => {
|
||||
const element = column as HTMLElement;
|
||||
if (element.dataset.marketColumnGroup) {
|
||||
return element.dataset.marketColumnGroup;
|
||||
}
|
||||
|
||||
return element.querySelector("a") ? "author" : "unknown";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readSelectionCellHeights() {
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'[data-testid="author-section"] [data-market-column-group="selection"] > .content-cell'
|
||||
),
|
||||
(cell) => (cell as HTMLElement).style.height
|
||||
);
|
||||
}
|
||||
|
||||
function readAuthorRowHiddenStates() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="author-cell-"]'),
|
||||
@@ -1002,3 +1071,25 @@ function readVisualCells(root: Element | null): HTMLElement[] {
|
||||
return cells.indexOf(left) - cells.indexOf(right);
|
||||
});
|
||||
}
|
||||
|
||||
function createRect({
|
||||
height,
|
||||
top
|
||||
}: {
|
||||
height: number;
|
||||
top: number;
|
||||
}): DOMRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
top,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: top
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url":"https://www.xingtu.cn/ad/creator/market"}
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("market-page-bridge", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div class="base-author-list"></div>';
|
||||
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
||||
vi.spyOn(window, "setInterval").mockReturnValue(0 as unknown as number);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (
|
||||
window as Window & {
|
||||
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
|
||||
}
|
||||
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("ignores non-search market list responses when capturing the export snapshot", async () => {
|
||||
const originalFetch = vi.fn(async () => ({
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
nickname: "推荐达人"
|
||||
},
|
||||
star_id: "recommend-1"
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total_count: 20
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
(
|
||||
window as Window & {
|
||||
fetch: typeof fetch;
|
||||
}
|
||||
).fetch = originalFetch as unknown as typeof fetch;
|
||||
|
||||
await import("../src/content/market/page-bridge");
|
||||
|
||||
await window.fetch("/gw/api/gauthor/demander_get_recommend_author_lists_v2", {
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*"
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
document.documentElement.getAttribute("data-sces-market-request-snapshot")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("captures the search_for_author_square request for silent export", async () => {
|
||||
const originalFetch = vi.fn(async () => ({
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
nickname: "搜索达人"
|
||||
},
|
||||
star_id: "search-1"
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total_count: 20
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
(
|
||||
window as Window & {
|
||||
fetch: typeof fetch;
|
||||
}
|
||||
).fetch = originalFetch as unknown as typeof fetch;
|
||||
|
||||
await import("../src/content/market/page-bridge");
|
||||
|
||||
await window.fetch("/gw/api/gsearch/search_for_author_square", {
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: "1"
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
document.documentElement.getAttribute(
|
||||
"data-sces-market-request-snapshot"
|
||||
) ?? "null"
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: "1"
|
||||
}
|
||||
}),
|
||||
method: "POST",
|
||||
url: "/gw/api/gsearch/search_for_author_square"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes core user ids from the live market list", async () => {
|
||||
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
|
||||
__vue__?: {
|
||||
_setupState?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
marketRoot.__vue__ = {
|
||||
_setupState: {
|
||||
marketState: {
|
||||
marketList: [
|
||||
{
|
||||
attribute_datas: {
|
||||
avg_search_after_view_rate_30d: "0.1234",
|
||||
core_user_id: "core-111",
|
||||
nickname: "搜索达人"
|
||||
},
|
||||
star_id: "search-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await import("../src/content/market/page-bridge");
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
document.documentElement.getAttribute("data-sces-market-rows") ?? "[]"
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
authorId: "search-1",
|
||||
authorName: "搜索达人",
|
||||
coreUserId: "core-111"
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,6 @@ describe("mock-protected-api", () => {
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
@@ -71,7 +70,7 @@ describe("mock-protected-api", () => {
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
acceptedCount: 1,
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchId: null,
|
||||
ok: true,
|
||||
source: "mock-batch-submit"
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
|
||||
|
||||
describe("package-release-archive", () => {
|
||||
test("creates a zip archive with a top-level dist folder", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "release-archive-"));
|
||||
const sourceDir = path.join(tempDir, "source");
|
||||
const archivePath = path.join(tempDir, "archive.zip");
|
||||
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(path.join(sourceDir, "hello.txt"), "hello world", "utf8");
|
||||
|
||||
await createReleaseArchive({
|
||||
archivePath,
|
||||
sourceDir
|
||||
});
|
||||
|
||||
const archive = await readFile(archivePath);
|
||||
expect(archive.byteLength).toBeGreaterThan(0);
|
||||
expect(archive.toString("utf8")).toContain("dist/hello.txt");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootPopup } from "../src/popup/index";
|
||||
|
||||
function flushTasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("popup-entry", () => {
|
||||
let dom: JSDOM;
|
||||
|
||||
@@ -22,6 +26,12 @@ describe("popup-entry", () => {
|
||||
}))
|
||||
});
|
||||
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-shell="root"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||
).not.toBeNull();
|
||||
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
||||
"登录"
|
||||
);
|
||||
@@ -47,10 +57,116 @@ describe("popup-entry", () => {
|
||||
}))
|
||||
});
|
||||
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-header="root"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||
).not.toBeNull();
|
||||
expect(dom.window.document.body.textContent).toContain("resource");
|
||||
expect(dom.window.document.body.textContent).toContain("token");
|
||||
});
|
||||
|
||||
test("shows available extension updates in the popup", async () => {
|
||||
const fetchUpdateManifest = vi.fn(async () => ({
|
||||
guideUrl: "https://cos.example.com/guide.pdf",
|
||||
latestVersion: "0.2.0421.3",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: ["支持检查更新"],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
}));
|
||||
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
currentVersion: "0.2.0421.2",
|
||||
document: dom.window.document,
|
||||
fetchUpdateManifest,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
userInfo: { name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
expect(fetchUpdateManifest).toHaveBeenCalledTimes(1);
|
||||
expect(dom.window.document.body.textContent).toContain("当前版本:0.2.0421.2");
|
||||
expect(dom.window.document.body.textContent).toContain("发现新版本:0.2.0421.3");
|
||||
expect(dom.window.document.body.textContent).toContain("支持检查更新");
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-update="card"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-download-update="button"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-download-guide="button"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("downloads update assets from popup buttons", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
userInfo: { name: "Dev" }
|
||||
}
|
||||
})
|
||||
.mockResolvedValue({ ok: true, type: "update:download-ack" });
|
||||
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
currentVersion: "0.2.0421.2",
|
||||
document: dom.window.document,
|
||||
fetchUpdateManifest: vi.fn(async () => ({
|
||||
guideUrl: "https://cos.example.com/guide.pdf",
|
||||
latestVersion: "0.2.0421.3",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: [],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
})),
|
||||
sendMessage
|
||||
});
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
(
|
||||
dom.window.document.querySelector(
|
||||
'[data-popup-download-update="button"]'
|
||||
) as HTMLButtonElement | null
|
||||
)?.click();
|
||||
(
|
||||
dom.window.document.querySelector(
|
||||
'[data-popup-download-guide="button"]'
|
||||
) as HTMLButtonElement | null
|
||||
)?.click();
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
filename: "star-chart-search-enhancer-internal.zip",
|
||||
type: "update:download",
|
||||
url: "https://cos.example.com/plugin.zip"
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
filename: "星图增强插件-超简单安装使用指南.pdf",
|
||||
type: "update:download",
|
||||
url: "https://cos.example.com/guide.pdf"
|
||||
});
|
||||
});
|
||||
|
||||
test("renders a protected api test button in the dev panel", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
@@ -150,6 +266,63 @@ describe("popup-entry", () => {
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
||||
});
|
||||
|
||||
test("shows the latest state without update actions", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
currentVersion: "0.0525.5",
|
||||
document: dom.window.document,
|
||||
fetchUpdateManifest: vi.fn(async () => ({
|
||||
guideUrl: "https://cos.example.com/guide.pdf",
|
||||
latestVersion: "0.0525.5",
|
||||
minSupportedVersion: "0.0525.5",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: ["支持检查更新"],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
userInfo: { name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
expect(dom.window.document.body.textContent).toContain("当前已是最新版本");
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-popup-download-update="button"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("shows a readable error state when the manifest fetch fails", async () => {
|
||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
currentVersion: "0.0525.5",
|
||||
document: dom.window.document,
|
||||
fetchUpdateManifest: vi.fn(async () => {
|
||||
throw new Error("network down");
|
||||
}),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
userInfo: { name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
expect(dom.window.document.body.textContent).toContain("暂时无法检查更新");
|
||||
expect(dom.window.document.body.textContent).toContain("network down");
|
||||
});
|
||||
|
||||
test("shows the auth error when sign-in fails", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildReleaseUploadTargets } from "../scripts/release-assets.mjs";
|
||||
|
||||
describe("release-assets", () => {
|
||||
test("maps release files to the COS object keys", () => {
|
||||
expect(
|
||||
buildReleaseUploadTargets({
|
||||
projectRoot: "/repo",
|
||||
releaseVersion: "0.0525.1"
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
cosKey: "star-chart-search-enhancer/latest.json",
|
||||
localPath: path.join("/repo", "release", "latest.json")
|
||||
},
|
||||
{
|
||||
cosKey:
|
||||
"star-chart-search-enhancer/releases/0.0525.1/star-chart-search-enhancer-internal.zip",
|
||||
localPath: path.join("/repo", "release", "star-chart-search-enhancer-internal.zip")
|
||||
},
|
||||
{
|
||||
cosKey:
|
||||
"star-chart-search-enhancer/releases/0.0525.1/星图增强插件-超简单安装使用指南.pdf",
|
||||
localPath: path.join("/repo", "release", "星图增强插件-超简单安装使用指南.pdf")
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
normalizeReleaseVersionTag,
|
||||
resolveReleaseVersion
|
||||
} from "../scripts/release-version.mjs";
|
||||
|
||||
describe("release-version", () => {
|
||||
test("normalizes a tag by stripping a leading v", () => {
|
||||
expect(normalizeReleaseVersionTag("v0.0525.1")).toBe("0.0525.1");
|
||||
expect(normalizeReleaseVersionTag("0.0525.1")).toBe("0.0525.1");
|
||||
});
|
||||
|
||||
test("prefers EXTENSION_VERSION over DRONE_TAG and fallback", () => {
|
||||
expect(
|
||||
resolveReleaseVersion(
|
||||
{
|
||||
DRONE_TAG: "0.0525.2",
|
||||
EXTENSION_VERSION: "v0.0525.3"
|
||||
},
|
||||
"0.2.0421.2"
|
||||
)
|
||||
).toBe("0.0525.3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createSilentExportController } from "../src/content/market/silent-export-controller";
|
||||
|
||||
describe("silent-export-controller", () => {
|
||||
test("replays exports from the live market page state when the request snapshot attribute is missing", async () => {
|
||||
document.body.innerHTML = '<div class="base-author-list"></div>';
|
||||
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
|
||||
__vue__?: {
|
||||
_setupState?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
marketRoot.__vue__ = {
|
||||
_setupState: {
|
||||
__$temp_1: {
|
||||
reqParams: {
|
||||
scene_param: {
|
||||
platform_source: 1
|
||||
},
|
||||
page_param: {
|
||||
limit: "20",
|
||||
page: "2"
|
||||
},
|
||||
search_param: {
|
||||
seach_type: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestedPages: number[] = [];
|
||||
const requestedHeaders: Array<HeadersInit | undefined> = [];
|
||||
const requestedUrls: string[] = [];
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async (url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
||||
page_param?: { page?: number | string };
|
||||
};
|
||||
const pageNo = Number(body.page_param?.page ?? 0);
|
||||
requestedPages.push(pageNo);
|
||||
requestedHeaders.push(init?.headers);
|
||||
requestedUrls.push(url);
|
||||
|
||||
return {
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
core_user_id: `core-${pageNo}`,
|
||||
nickname: `达人${pageNo}`,
|
||||
price_20_60: pageNo * 1000
|
||||
},
|
||||
star_id: String(pageNo)
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
limit: 20,
|
||||
page: pageNo,
|
||||
total_count: 100
|
||||
}
|
||||
}),
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 2
|
||||
});
|
||||
|
||||
expect(requestedPages).toEqual([2, 3]);
|
||||
expect(
|
||||
requestedUrls.every((url) =>
|
||||
url.endsWith("/gw/api/gsearch/search_for_author_square")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(requestedHeaders).toEqual([
|
||||
{
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Agw-Js-Conv": "str",
|
||||
"Content-Type": "application/json",
|
||||
"x-login-source": "1"
|
||||
},
|
||||
{
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Agw-Js-Conv": "str",
|
||||
"Content-Type": "application/json",
|
||||
"x-login-source": "1"
|
||||
}
|
||||
]);
|
||||
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
|
||||
expect(records?.map((record) => record.coreUserId)).toEqual([
|
||||
"core-2",
|
||||
"core-3"
|
||||
]);
|
||||
});
|
||||
|
||||
test("replays paged exports when the page number is nested inside the request body", async () => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: 2
|
||||
}
|
||||
}),
|
||||
method: "POST",
|
||||
url: "https://xingtu.cn/api/mock-market-search"
|
||||
})
|
||||
);
|
||||
|
||||
const requestedPages: number[] = [];
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
||||
page_param?: { page?: number };
|
||||
};
|
||||
const pageNo = body.page_param?.page ?? 0;
|
||||
requestedPages.push(pageNo);
|
||||
|
||||
return {
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
nickname: `达人${pageNo}`,
|
||||
price_20_60: pageNo * 1000
|
||||
},
|
||||
star_id: String(pageNo)
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
limit: 20,
|
||||
page: pageNo,
|
||||
total_count: 100
|
||||
}
|
||||
}),
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 2
|
||||
});
|
||||
|
||||
expect(requestedPages).toEqual([2, 3]);
|
||||
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
test("starts from page 1 when the captured request omits an explicit page number", async () => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
filters: {
|
||||
keyword: "test"
|
||||
}
|
||||
}),
|
||||
method: "POST",
|
||||
url: "https://xingtu.cn/api/mock-market-search"
|
||||
})
|
||||
);
|
||||
|
||||
const requestedPages: number[] = [];
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
|
||||
const page = body.page ?? 0;
|
||||
requestedPages.push(page);
|
||||
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
marketList: [
|
||||
{
|
||||
attribute_datas: {
|
||||
nickname: `达人${page}`,
|
||||
price_20_60: page * 1000
|
||||
},
|
||||
star_id: String(page)
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 2
|
||||
});
|
||||
|
||||
expect(requestedPages).toEqual([1, 2]);
|
||||
expect(records?.map((record) => record.authorId)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
test("accepts snapshot headers that contain non-string values from the live XHR capture", async () => {
|
||||
document.body.innerHTML = "";
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: "1",
|
||||
limit: "20"
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"x-login-source": 1,
|
||||
"Agw-Js-Conv": "str"
|
||||
},
|
||||
method: "POST",
|
||||
url: "/gw/api/gsearch/search_for_author_square"
|
||||
})
|
||||
);
|
||||
|
||||
const requestedPages: number[] = [];
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
||||
page_param?: { page?: number | string };
|
||||
};
|
||||
requestedPages.push(Number(body.page_param?.page ?? 0));
|
||||
|
||||
return {
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
nickname: "达人1"
|
||||
},
|
||||
star_id: "1"
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total_count: 20
|
||||
}
|
||||
}),
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 1
|
||||
});
|
||||
|
||||
expect(requestedPages).toEqual([1]);
|
||||
expect(records?.map((record) => record.authorId)).toEqual(["1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
compareExtensionVersions,
|
||||
parseUpdateManifest
|
||||
} from "../src/shared/update-check";
|
||||
|
||||
describe("update-check", () => {
|
||||
test("compares dotted extension versions numerically", () => {
|
||||
expect(compareExtensionVersions("0.2.0421.3", "0.2.0421.2")).toBeGreaterThan(0);
|
||||
expect(compareExtensionVersions("0.2.10.0", "0.2.9.9")).toBeGreaterThan(0);
|
||||
expect(compareExtensionVersions("0.2.0421.2", "0.2.0421.2")).toBe(0);
|
||||
expect(compareExtensionVersions("0.2.0421.1", "0.2.0421.2")).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("parses a valid update manifest", () => {
|
||||
expect(
|
||||
parseUpdateManifest({
|
||||
guideUrl: "https://cos.example.com/guide.pdf",
|
||||
latestVersion: "0.2.0421.3",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: ["支持检查更新"],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
})
|
||||
).toEqual({
|
||||
guideUrl: "https://cos.example.com/guide.pdf",
|
||||
latestVersion: "0.2.0421.3",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: ["支持检查更新"],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid update manifests", () => {
|
||||
expect(parseUpdateManifest({ latestVersion: "0.2.0421.3" })).toBeNull();
|
||||
expect(
|
||||
parseUpdateManifest({
|
||||
guideUrl: "javascript:alert(1)",
|
||||
latestVersion: "0.2.0421.3",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-19",
|
||||
releaseNotes: [],
|
||||
zipUrl: "https://cos.example.com/plugin.zip"
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { UPDATE_MANIFEST_URL } from "../src/shared/update-config";
|
||||
|
||||
describe("update-config", () => {
|
||||
test("points popup update checks at the COS latest manifest", () => {
|
||||
expect(UPDATE_MANIFEST_URL).toBe(
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createLatestManifest } from "../scripts/write-latest-manifest-data.mjs";
|
||||
|
||||
describe("write-latest-manifest-data", () => {
|
||||
test("builds COS asset URLs from the release base", () => {
|
||||
expect(
|
||||
createLatestManifest({
|
||||
latestVersion: "0.2.0421.2",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publicBaseUrl:
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2",
|
||||
publishedAt: "2026-05-25"
|
||||
})
|
||||
).toEqual({
|
||||
guideUrl:
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/星图增强插件-超简单安装使用指南.pdf",
|
||||
latestVersion: "0.2.0421.2",
|
||||
minSupportedVersion: "0.2.0421.2",
|
||||
publishedAt: "2026-05-25",
|
||||
releaseNotes: [
|
||||
"支持在插件弹窗中检查新版本",
|
||||
"支持一键下载最新版插件压缩包和使用说明"
|
||||
],
|
||||
zipUrl:
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/star-chart-search-enhancer-internal.zip"
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user