Compare commits
No commits in common. "main" and "v0.2.0421.2" have entirely different histories.
main
...
v0.2.0421.
34
.drone.yml
34
.drone.yml
@ -1,34 +0,0 @@
|
||||
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
.gitignore
vendored
26
.gitignore
vendored
@ -1,30 +1,4 @@
|
||||
.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
|
||||
|
||||
206
README.md
206
README.md
@ -1,198 +1,36 @@
|
||||
# 星图增强插件
|
||||
# Star Chart Search Enhancer
|
||||
|
||||
这是一个供公司内部使用的 Chrome MV3 插件,用于增强巨量星图达人市场页面的使用体验。
|
||||
Chrome MV3 extension for the Xingtu creator market page.
|
||||
|
||||
主要功能:
|
||||
|
||||
- 在星图达人列表页补充插件侧数据列
|
||||
- 支持勾选部分达人后导出 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`
|
||||
|
||||
---
|
||||
|
||||
## 八、常用命令
|
||||
## Development
|
||||
|
||||
```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
|
||||
|
||||
- 扩展 ID 已通过 `manifest.key` 固定
|
||||
- 不要泄露本地私钥文件 `.local/extension-key.pem`
|
||||
- 如果后端地址发生变化,需要同步更新:
|
||||
- `scripts/manifest.mjs`
|
||||
- 对应后端配置文件
|
||||
- 相关文档
|
||||
## Current Scope
|
||||
|
||||
---
|
||||
- Adds two after-search-rate columns to the Xingtu market list
|
||||
- Hydrates the current page immediately
|
||||
- Provides plugin-owned filter, sort, and CSV export controls
|
||||
- Triggers full-scan flow only when filter, sort, or export is used
|
||||
|
||||
## 十、当前状态
|
||||
## Manual Verification
|
||||
|
||||
当前项目已经支持:
|
||||
|
||||
- 新固定扩展 ID
|
||||
- 内部压缩包分发
|
||||
- 自定义批次名称弹窗
|
||||
- 后台静默导出
|
||||
- 批次提交不再由前端生成 `batchId`
|
||||
1. Load the unpacked extension from `dist/`
|
||||
2. Open `https://xingtu.cn/ad/creator/market`
|
||||
3. Confirm the two new columns appear
|
||||
4. Confirm current-page rows move through loading and then render values or failure states
|
||||
5. Apply a threshold filter and confirm the list hides unmatched rows
|
||||
6. Apply a sort and confirm row order changes
|
||||
7. Export CSV and confirm the file includes plugin status and after-search-rate fields
|
||||
|
||||
@ -1,540 +0,0 @@
|
||||
# 星图增强插件使用说明
|
||||
|
||||
适用对象:AIGC 部门同事,默认不需要任何编程基础。
|
||||
|
||||
这份说明会教你:
|
||||
|
||||
- 如何安装插件
|
||||
- 如何登录插件
|
||||
- 如何在星图页面使用导出和提交批次功能
|
||||
- 如何更新插件
|
||||
- 遇到问题时应该怎么处理
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么
|
||||
|
||||
这是一个给巨量星图达人列表页使用的 Chrome 插件。
|
||||
|
||||
它的主要作用是:
|
||||
|
||||
- 在星图达人列表页面提供增强数据
|
||||
- 支持勾选部分达人后导出 CSV
|
||||
- 支持把达人列表提交为批次
|
||||
|
||||
插件名称:
|
||||
|
||||
- `Star Chart Search Enhancer`
|
||||
|
||||
当前固定扩展 ID:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
如果你在 Chrome 扩展页里看到这个 ID,说明装的是正确版本。
|
||||
|
||||
---
|
||||
|
||||
## 二、安装前准备
|
||||
|
||||
开始前请先确认:
|
||||
|
||||
1. 你使用的是 `Google Chrome` 浏览器。
|
||||
2. 你已经拿到了同事发给你的插件压缩包:
|
||||
- `star-chart-search-enhancer-internal.zip`
|
||||
3. 你可以正常访问:
|
||||
- 巨量星图
|
||||
- 公司登录系统
|
||||
|
||||
注意:
|
||||
|
||||
- 这个插件不是从 Chrome 网上应用店安装的。
|
||||
- 你必须手动解压并加载。
|
||||
|
||||
---
|
||||
|
||||
## 三、第一次安装
|
||||
|
||||
### 第 1 步:解压压缩包
|
||||
|
||||
找到收到的压缩包:
|
||||
|
||||
- `star-chart-search-enhancer-internal.zip`
|
||||
|
||||
右键解压。
|
||||
|
||||
解压后你会得到一个文件夹。请记住这个文件夹的位置,不要删除。
|
||||
|
||||
建议放在:
|
||||
|
||||
- 桌面
|
||||
- 下载目录
|
||||
- 或者一个不会随手清理掉的位置
|
||||
|
||||
不要放在:
|
||||
|
||||
- 临时目录
|
||||
- 会被自动清理的文件夹
|
||||
|
||||
原因:
|
||||
|
||||
- Chrome 加载的是这个文件夹本身
|
||||
- 如果文件夹被删了,插件就失效了
|
||||
|
||||
### 第 2 步:打开 Chrome 扩展页面
|
||||
|
||||
在 Chrome 地址栏输入:
|
||||
|
||||
```text
|
||||
chrome://extensions
|
||||
```
|
||||
|
||||
然后按回车。
|
||||
|
||||
### 第 3 步:打开开发者模式
|
||||
|
||||
在扩展页面右上角,找到:
|
||||
|
||||
- `开发者模式`
|
||||
|
||||
把它打开。
|
||||
|
||||
### 第 4 步:加载插件
|
||||
|
||||
点击左上角或页面上的:
|
||||
|
||||
- `加载已解压的扩展程序`
|
||||
|
||||
然后选择刚才解压出来的那个文件夹。
|
||||
|
||||
选择后,Chrome 会自动加载插件。
|
||||
|
||||
### 第 5 步:确认是否安装成功
|
||||
|
||||
在扩展列表中找到:
|
||||
|
||||
- `Star Chart Search Enhancer`
|
||||
|
||||
点击 `详情` 后,确认扩展 ID 是:
|
||||
|
||||
- `pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
如果是这个 ID,说明安装的是正确版本。
|
||||
|
||||
---
|
||||
|
||||
## 四、固定到浏览器工具栏
|
||||
|
||||
为了后续登录方便,建议把插件固定到工具栏。
|
||||
|
||||
操作方法:
|
||||
|
||||
1. 点击 Chrome 右上角的拼图图标
|
||||
2. 找到 `Star Chart Search Enhancer`
|
||||
3. 点击右边的图钉
|
||||
|
||||
固定后,浏览器右上角就能直接看到插件图标。
|
||||
|
||||
---
|
||||
|
||||
## 五、如何登录
|
||||
|
||||
### 第 1 步:打开插件弹窗
|
||||
|
||||
点击 Chrome 右上角工具栏中的插件图标。
|
||||
|
||||
### 第 2 步:点击登录
|
||||
|
||||
如果当前未登录,你会看到登录按钮。
|
||||
|
||||
点击:
|
||||
|
||||
- `登录 Logto`
|
||||
|
||||
### 第 3 步:完成登录
|
||||
|
||||
系统会自动跳转到公司登录流程。
|
||||
|
||||
按页面提示完成登录。
|
||||
|
||||
### 第 4 步:确认登录成功
|
||||
|
||||
登录成功后,再次点击插件图标。
|
||||
|
||||
你会看到:
|
||||
|
||||
- 已登录状态
|
||||
- 你的用户名信息
|
||||
- 退出登录按钮
|
||||
|
||||
如果这里能正常显示,说明登录已经成功。
|
||||
|
||||
---
|
||||
|
||||
## 六、进入星图页面
|
||||
|
||||
插件只在巨量星图达人市场页面生效。
|
||||
|
||||
请打开类似下面的页面:
|
||||
|
||||
```text
|
||||
https://xingtu.cn/ad/creator/market
|
||||
```
|
||||
|
||||
进入后,等待页面加载完成。
|
||||
|
||||
如果你已经登录插件,页面上会出现插件自己的工具栏和增强列。
|
||||
|
||||
---
|
||||
|
||||
## 七、页面上你会看到什么
|
||||
|
||||
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
|
||||
|
||||
- `导出选中达人数据` 按钮
|
||||
- `提交批次` 按钮
|
||||
- 状态提示文字
|
||||
- 每一行达人前面的勾选框
|
||||
|
||||
其中:
|
||||
|
||||
- `导出选中达人数据`:导出已勾选达人,包含内容数据、效果预估、画像等维度
|
||||
- `提交批次`:把当前选择的数据提交为批次
|
||||
|
||||
---
|
||||
|
||||
## 八、如何选择达人
|
||||
|
||||
页面上每个达人前面都有一个勾选框。
|
||||
|
||||
你可以:
|
||||
|
||||
- 单独勾选某几个达人
|
||||
- 勾选表头复选框,快速全选当前页
|
||||
|
||||
规则说明:
|
||||
|
||||
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
|
||||
- 导出达人数据必须先勾选达人
|
||||
- 提交批次时,如果你没有勾选任何达人,则默认处理当前列表范围内的达人
|
||||
|
||||
---
|
||||
|
||||
## 九、导出达人数据 CSV 的方法
|
||||
|
||||
导出达人数据 CSV 用来导出内容数据、效果预估、画像和秒思 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. 打开巨量星图达人市场页面开始使用
|
||||
```
|
||||
@ -1,97 +0,0 @@
|
||||
# Internal Extension Distribution
|
||||
|
||||
## Fixed Extension ID
|
||||
|
||||
- Manifest key is fixed in the project.
|
||||
- The unpacked extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
||||
- Update Logto with:
|
||||
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/callback`
|
||||
- `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/`
|
||||
- `chrome-extension://pkjopdibdnomhogjheclhnknmejccffg`
|
||||
|
||||
## Internal Package
|
||||
|
||||
1. Run `npm test`.
|
||||
2. Run `npm run package:internal`.
|
||||
3. 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`.
|
||||
|
||||
## One-Time Bridge Upgrade
|
||||
|
||||
Some coworkers may still be using an older unpacked build whose popup cannot read the COS update manifest and only shows:
|
||||
|
||||
- `暂时无法检查更新`
|
||||
|
||||
For those users, ask them to do one manual bridge upgrade with the newest ZIP:
|
||||
|
||||
1. Download the newest `star-chart-search-enhancer-internal.zip`.
|
||||
2. Unzip it and get the new `dist/` folder.
|
||||
3. Re-load that `dist/` folder in `chrome://extensions`.
|
||||
|
||||
After this one-time bridge upgrade, future updates should continue using the same `dist/` layout.
|
||||
|
||||
## 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 `localhost:8083`, update `scripts/manifest.mjs` before packaging.
|
||||
@ -1,685 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>星图插件工具栏改版样例</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f5f7fa;
|
||||
--panel: #ffffff;
|
||||
--line: #e7ebf0;
|
||||
--text: #20242a;
|
||||
--muted: #6b7280;
|
||||
--soft: #f8fafc;
|
||||
--brand: #ff2f6d;
|
||||
--brand-dark: #85172d;
|
||||
--brand-soft: #fff0f5;
|
||||
--blue: #2563eb;
|
||||
--green: #0f8a5f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1180px;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 24px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background:
|
||||
linear-gradient(135deg, #ff245f 0 38%, transparent 39%),
|
||||
linear-gradient(45deg, #27c7f2 0 50%, #3664ff 51%);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
color: #2f3540;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav .active {
|
||||
color: var(--brand);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav .active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -17px;
|
||||
height: 4px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.small-btn,
|
||||
.pink-btn {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #d8dee8;
|
||||
background: #fff;
|
||||
color: #303744;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pink-btn {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 18px 24px 40px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.searchbar,
|
||||
.filters,
|
||||
.results {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 0 18px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 440px;
|
||||
height: 36px;
|
||||
border: 1px solid #ff8ab2;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 108px 1fr;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-side {
|
||||
background: #fbfcfe;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.filter-side div {
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.filter-main {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.filter-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
width: 72px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.soft-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.soft-chip {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: #414854;
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 24px 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.count strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.toolbar-shell {
|
||||
margin: 18px 24px 0;
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fff, #fbfcff);
|
||||
}
|
||||
|
||||
.action-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
height: 34px;
|
||||
border: 1px solid #7b1a2d;
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
background: #7b1a2d;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-cluster {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 8px;
|
||||
background: #eef5ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select,
|
||||
.input {
|
||||
height: 34px;
|
||||
border: 1px solid #d4dbe6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #202938;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 14px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 8px;
|
||||
background: #eef5ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d9efe7;
|
||||
border-radius: 8px;
|
||||
background: #f0fbf6;
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-rule strong {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.metric-rule small {
|
||||
margin-left: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.and {
|
||||
color: #0f8a5f;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.metric span,
|
||||
.metric b {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric b {
|
||||
color: var(--green);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric input {
|
||||
min-width: 0;
|
||||
width: 58px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.native-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.native-btn {
|
||||
height: 34px;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #303744;
|
||||
padding: 0 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 46px;
|
||||
background: #fbfcfe;
|
||||
color: #5d6675;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #f4b66b, #7660d6);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--brand);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.metric-grid {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="logo"></span>巨量星图</div>
|
||||
<nav class="nav">
|
||||
<span>首页</span>
|
||||
<span>我的星图</span>
|
||||
<span>找灵感</span>
|
||||
<span class="active">找达人</span>
|
||||
<span>找活动</span>
|
||||
<span>助投放</span>
|
||||
</nav>
|
||||
<div class="top-actions">
|
||||
<button class="small-btn">达人清单</button>
|
||||
<button class="pink-btn">+ 发布任务</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<section class="banner">「精选品牌伙伴计划」优质达人合作推荐</section>
|
||||
|
||||
<section class="searchbar">
|
||||
<button class="tab">内容找人</button>
|
||||
<button class="tab active">昵称找人</button>
|
||||
<input class="search-input" value="抖音 输入达人昵称、抖音号或星图ID" />
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
<aside class="filter-side">
|
||||
<div>合作诉求</div>
|
||||
<div>匹配度</div>
|
||||
<div>性价比</div>
|
||||
<div>主题推荐</div>
|
||||
</aside>
|
||||
<div class="filter-main">
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">合作对象</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">明星</span>
|
||||
<span class="soft-chip">短视频达人</span>
|
||||
<span class="chip">短剧演员</span>
|
||||
<span class="chip">短直达人</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">适配行业</span>
|
||||
<span class="chip">不限</span>
|
||||
<span class="soft-chip">品牌曝光</span>
|
||||
<span class="chip">破圈种草</span>
|
||||
<span class="chip">行动转化</span>
|
||||
<span class="chip">品牌5A</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">达人类型</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">美妆</span>
|
||||
<span class="chip">萌宠</span>
|
||||
<span class="chip">测评</span>
|
||||
<span class="chip">旅行</span>
|
||||
<span class="chip">母婴亲子</span>
|
||||
<span class="chip">科技数码</span>
|
||||
<span class="chip">生活家居</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">内容主题</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">妆容改造</span>
|
||||
<span class="chip">亲子育儿</span>
|
||||
<span class="chip">精彩生活</span>
|
||||
<span class="chip">手机数码</span>
|
||||
<span class="chip">萌宠养护</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="results">
|
||||
<div class="result-top">
|
||||
<div class="count">找到 <strong>10000+</strong> 个达人</div>
|
||||
</div>
|
||||
|
||||
<section class="toolbar-shell" aria-label="插件工具栏改版样例">
|
||||
<div class="toolbar-head">
|
||||
<div class="action-cluster">
|
||||
<button class="tool-btn">导出选中达人数据</button>
|
||||
<button class="tool-btn">按星图ID导出</button>
|
||||
<button class="tool-btn">选择字段</button>
|
||||
<button class="tool-btn">提交批次</button>
|
||||
</div>
|
||||
|
||||
<div class="control-cluster">
|
||||
<span class="label">视频口径</span>
|
||||
<select class="select">
|
||||
<option>星图视频</option>
|
||||
<option>个人视频</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>只看指派</option>
|
||||
<option>不限指派</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>排除营销</option>
|
||||
<option>不排除营销</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>近90天</option>
|
||||
<option>近30天</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="status">批次提交成功</span>
|
||||
|
||||
<div class="native-actions">
|
||||
<button class="native-btn">自定义指标</button>
|
||||
<button class="native-btn">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-body">
|
||||
<div class="metric-title">传播指标筛选</div>
|
||||
<div class="metric-rule">全部满足<strong>AND</strong><small>每项取值 ≥ 输入值</small></div>
|
||||
<div class="metric-grid">
|
||||
<label class="metric"><span>评论</span><b>≥</b><input value="1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>时长</span><b>≥</b><input value="5" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>点赞</span><b>≥</b><input value="10" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>转发</span><b>≥</b><input value="0" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>完播率</span><b>≥</b><input value="1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>互动率</span><b>≥</b><input value="0.1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>播放中位数</span><b>≥</b><input value="1000" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" /> 全选</th>
|
||||
<th>达人信息</th>
|
||||
<th>代表视频</th>
|
||||
<th>达人类型</th>
|
||||
<th>内容主题</th>
|
||||
<th>粉丝数</th>
|
||||
<th>预期CPM</th>
|
||||
<th>完播率</th>
|
||||
<th>21-60s报价</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><input type="checkbox" /></td>
|
||||
<td>
|
||||
<div class="author">
|
||||
<span class="avatar"></span>
|
||||
<div>
|
||||
<strong>柯铭</strong><br />
|
||||
<span style="color: #7a8493">男 · 北京市 · 抖音精选</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>2 个视频</td>
|
||||
<td>萌宠</td>
|
||||
<td>国内旅行 / 昆虫科普</td>
|
||||
<td>1,471.4w</td>
|
||||
<td>43.5</td>
|
||||
<td>26.2%</td>
|
||||
<td class="price">¥600,000</td>
|
||||
<td><button class="pink-btn">下单</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,853 +0,0 @@
|
||||
# Logto Auth Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Logto-based Chrome extension authentication with a popup login entry, background-owned auth flow, and content-script gating so the Xingtu enhancer is unusable until the user is authenticated.
|
||||
|
||||
**Architecture:** Keep Logto-specific behavior inside `src/background/auth/*` and expose a small message contract from `src/shared/auth-messages.ts`. Add a standalone `popup` entry for sign-in / sign-out and dev diagnostics. Gate existing market content initialization behind an auth check so the page only boots the current enhancer after authentication succeeds.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup build script, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/shared/auth-config.ts`
|
||||
- `src/shared/auth-messages.ts`
|
||||
- `src/background/auth/types.ts`
|
||||
- `src/background/auth/state.ts`
|
||||
- `src/background/auth/client.ts`
|
||||
- `src/background/auth/controller.ts`
|
||||
- `src/popup/index.html`
|
||||
- `src/popup/index.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `src/content/market/auth-gate.ts`
|
||||
- `tests/auth-config.test.ts`
|
||||
- `tests/auth-messages.test.ts`
|
||||
- `tests/background-auth-controller.test.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/market-auth-gating.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `src/manifest.json`
|
||||
- `scripts/build.mjs`
|
||||
- `src/background/index.ts`
|
||||
- `src/content/index.ts`
|
||||
- `src/content/market/index.ts`
|
||||
- `tests/background-index.test.ts`
|
||||
- `tests/manifest.test.ts`
|
||||
- `README.md`
|
||||
- `externaldocs/2026-04-21-logto-auth-design.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/shared/auth-config.ts`: parse placeholder config, validate required fields, expose a stable config object.
|
||||
- `src/shared/auth-messages.ts`: define request / response types and type guards for popup-content-background communication.
|
||||
- `src/background/auth/*`: isolate Logto client creation, auth state mapping, and runtime message handling.
|
||||
- `src/popup/*`: render login/logout UI and dev diagnostics using background messages only.
|
||||
- `src/content/market/auth-gate.ts`: render the disabled state and “go login” guidance when auth is missing.
|
||||
|
||||
## Task 1: Shared Auth Contract And Config
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/auth-config.ts`
|
||||
- Create: `src/shared/auth-messages.ts`
|
||||
- Test: `tests/auth-config.test.ts`
|
||||
- Test: `tests/auth-messages.test.ts`
|
||||
- Modify: `package.json`
|
||||
- Modify: `package-lock.json`
|
||||
|
||||
- [ ] **Step 1: Add the Logto SDK dependency in the manifest of the repo**
|
||||
|
||||
Update `package.json` dependencies to include:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@logto/chrome-extension": "^<latest-compatible>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `npm install`
|
||||
Expected: `package-lock.json` includes `@logto/chrome-extension`
|
||||
|
||||
- [ ] **Step 2: Write the failing config tests**
|
||||
|
||||
Create `tests/auth-config.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { readAuthConfig } from "../src/shared/auth-config";
|
||||
|
||||
describe("auth-config", () => {
|
||||
test("returns the placeholder config in development", () => {
|
||||
expect(readAuthConfig()).toEqual({
|
||||
apiResource: "<your-global-api-resource-indicator>",
|
||||
appId: "<chrome-extension-app-id>",
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://<your-tenant>.logto.app",
|
||||
scopes: ["openid", "profile", "offline_access"]
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty endpoint values", () => {
|
||||
expect(() =>
|
||||
readAuthConfig({
|
||||
logtoEndpoint: ""
|
||||
})
|
||||
).toThrow(/logtoEndpoint/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/auth-config.test.ts`
|
||||
Expected: FAIL because `readAuthConfig` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Write the failing message contract tests**
|
||||
|
||||
Create `tests/auth-messages.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isAuthRequestMessage,
|
||||
isAuthResponseMessage
|
||||
} from "../src/shared/auth-messages";
|
||||
|
||||
describe("auth-messages", () => {
|
||||
test("accepts a get-state request", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:get-state" })).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown auth requests", () => {
|
||||
expect(isAuthRequestMessage({ type: "auth:wat" })).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts a successful auth response envelope", () => {
|
||||
expect(
|
||||
isAuthResponseMessage({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/auth-messages.test.ts`
|
||||
Expected: FAIL because helpers and types do not exist yet
|
||||
|
||||
- [ ] **Step 4: Implement the minimal shared config**
|
||||
|
||||
Create `src/shared/auth-config.ts` with:
|
||||
|
||||
```ts
|
||||
export interface AuthConfig {
|
||||
apiResource: string;
|
||||
appId: string;
|
||||
enableDevAuthPanel: boolean;
|
||||
logtoEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
const defaultAuthConfig: AuthConfig = {
|
||||
apiResource: "<your-global-api-resource-indicator>",
|
||||
appId: "<chrome-extension-app-id>",
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://<your-tenant>.logto.app",
|
||||
scopes: ["openid", "profile", "offline_access"]
|
||||
};
|
||||
|
||||
export function readAuthConfig(
|
||||
overrides: Partial<AuthConfig> = {}
|
||||
): AuthConfig {
|
||||
const nextConfig = { ...defaultAuthConfig, ...overrides };
|
||||
if (!nextConfig.logtoEndpoint.trim()) {
|
||||
throw new Error("auth config logtoEndpoint is required");
|
||||
}
|
||||
if (!nextConfig.appId.trim()) {
|
||||
throw new Error("auth config appId is required");
|
||||
}
|
||||
if (!nextConfig.apiResource.trim()) {
|
||||
throw new Error("auth config apiResource is required");
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement the minimal shared message contract**
|
||||
|
||||
Create `src/shared/auth-messages.ts` with:
|
||||
|
||||
```ts
|
||||
export type AuthRequestMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
|
||||
export interface AuthStateValue {
|
||||
accessTokenExpiresAt?: number | null;
|
||||
isAuthenticated: boolean;
|
||||
lastError?: string | null;
|
||||
resource?: string | null;
|
||||
scopes?: string[];
|
||||
tokenAvailable?: boolean;
|
||||
userInfo?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AuthResponseMessage =
|
||||
| { ok: true; type: "auth:state"; value: AuthStateValue }
|
||||
| { ok: true; type: "auth:token"; value: { accessToken: string } }
|
||||
| { ok: true; type: "auth:ack" }
|
||||
| { ok: false; type: "auth:error"; error: string };
|
||||
```
|
||||
|
||||
Also add `isAuthRequestMessage()` and `isAuthResponseMessage()` type guards.
|
||||
|
||||
- [ ] **Step 6: Run the shared auth tests and verify green**
|
||||
|
||||
Run: `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the shared contract slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json src/shared/auth-config.ts src/shared/auth-messages.ts tests/auth-config.test.ts tests/auth-messages.test.ts
|
||||
git commit -m "feat: add shared auth config and message contracts"
|
||||
```
|
||||
|
||||
## Task 2: Background Auth Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `src/background/auth/types.ts`
|
||||
- Create: `src/background/auth/state.ts`
|
||||
- Create: `src/background/auth/client.ts`
|
||||
- Create: `src/background/auth/controller.ts`
|
||||
- Modify: `src/background/index.ts`
|
||||
- Test: `tests/background-auth-controller.test.ts`
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing background auth controller tests**
|
||||
|
||||
Create `tests/background-auth-controller.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createAuthController } from "../src/background/auth/controller";
|
||||
|
||||
describe("background-auth-controller", () => {
|
||||
test("returns unauthenticated state when the client is logged out", async () => {
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect(controller.getAuthState()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthenticated: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("delegates sign in to the auth client", async () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => false),
|
||||
signIn,
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await controller.signIn();
|
||||
expect(signIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/background-auth-controller.test.ts`
|
||||
Expected: FAIL because the controller does not exist
|
||||
|
||||
- [ ] **Step 2: Extend the existing background runtime test with auth messages**
|
||||
|
||||
Modify `tests/background-index.test.ts` to add:
|
||||
|
||||
```ts
|
||||
test("responds to auth:get-state with auth status", async () => {
|
||||
const listeners: Array<any> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(),
|
||||
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: FAIL because `registerBackgroundMessageHandler` only supports CSV download
|
||||
|
||||
- [ ] **Step 3: Implement the minimal background auth controller**
|
||||
|
||||
Create `src/background/auth/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface AuthClientLike {
|
||||
getAccessToken(resource?: string): Promise<string>;
|
||||
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
|
||||
isAuthenticated(): Promise<boolean>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/background/auth/state.ts` with a helper that maps client state into the shared `AuthStateValue`.
|
||||
|
||||
Create `src/background/auth/controller.ts` with:
|
||||
|
||||
```ts
|
||||
export function createAuthController(options: {
|
||||
authClient: AuthClientLike;
|
||||
config?: AuthConfig;
|
||||
}) {
|
||||
return {
|
||||
async getAuthState() {
|
||||
const isAuthenticated = await options.authClient.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
return { isAuthenticated: false, resource: options.config?.apiResource ?? null };
|
||||
}
|
||||
const claims = await options.authClient.getIdTokenClaims();
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
resource: options.config?.apiResource ?? null,
|
||||
scopes: options.config?.scopes ?? [],
|
||||
userInfo: {
|
||||
email: typeof claims?.email === "string" ? claims.email : undefined,
|
||||
name: typeof claims?.name === "string" ? claims.name : undefined,
|
||||
sub: typeof claims?.sub === "string" ? claims.sub : undefined,
|
||||
username: typeof claims?.username === "string" ? claims.username : undefined
|
||||
}
|
||||
};
|
||||
},
|
||||
signIn: () => options.authClient.signIn(),
|
||||
signOut: () => options.authClient.signOut(),
|
||||
async getAccessToken() {
|
||||
return options.authClient.getAccessToken(options.config?.apiResource);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the Logto client factory wrapper**
|
||||
|
||||
Create `src/background/auth/client.ts` as the only place that imports `@logto/chrome-extension`:
|
||||
|
||||
```ts
|
||||
import LogtoClient from "@logto/chrome-extension";
|
||||
|
||||
import { readAuthConfig } from "../../shared/auth-config";
|
||||
|
||||
export function createLogtoAuthClient() {
|
||||
const config = readAuthConfig();
|
||||
return new LogtoClient({
|
||||
appId: config.appId,
|
||||
endpoint: config.logtoEndpoint,
|
||||
resources: [config.apiResource],
|
||||
scopes: config.scopes
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Integrate auth handling into `src/background/index.ts`**
|
||||
|
||||
Refactor `registerBackgroundMessageHandler()` to accept optional dependencies:
|
||||
|
||||
```ts
|
||||
registerBackgroundMessageHandler(chromeLike?, {
|
||||
authController?: createAuthController(...)
|
||||
});
|
||||
```
|
||||
|
||||
Handle:
|
||||
|
||||
- `auth:get-state` -> `{ ok: true, type: "auth:state", value }`
|
||||
- `auth:sign-in` -> `{ ok: true, type: "auth:ack" }`
|
||||
- `auth:sign-out` -> `{ ok: true, type: "auth:ack" }`
|
||||
- `auth:get-access-token` -> `{ ok: true, type: "auth:token", value: { accessToken } }`
|
||||
|
||||
Keep the existing CSV download path intact.
|
||||
|
||||
- [ ] **Step 6: Run background tests and verify green**
|
||||
|
||||
Run: `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the background auth slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts src/background/auth tests/background-auth-controller.test.ts tests/background-index.test.ts
|
||||
git commit -m "feat: add background auth controller"
|
||||
```
|
||||
|
||||
## Task 3: Popup Entry, Manifest, And Build Output
|
||||
|
||||
**Files:**
|
||||
- Create: `src/popup/index.html`
|
||||
- Create: `src/popup/index.ts`
|
||||
- Create: `src/popup/view.ts`
|
||||
- Modify: `src/manifest.json`
|
||||
- Modify: `scripts/build.mjs`
|
||||
- Modify: `tests/manifest.test.ts`
|
||||
- Test: `tests/popup-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup rendering tests**
|
||||
|
||||
Create `tests/popup-entry.test.ts` with a JSDOM fixture:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootPopup } from "../src/popup/index";
|
||||
|
||||
describe("popup-entry", () => {
|
||||
test("renders a sign-in button when unauthenticated", async () => {
|
||||
document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.querySelector("button")?.textContent).toContain("登录");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: FAIL because popup files do not exist
|
||||
|
||||
- [ ] **Step 2: Extend manifest tests for auth permissions and popup**
|
||||
|
||||
Modify `tests/manifest.test.ts` to assert:
|
||||
|
||||
```ts
|
||||
expect(manifest.permissions).toEqual(
|
||||
expect.arrayContaining(["downloads", "identity", "storage"])
|
||||
);
|
||||
expect(manifest.action?.default_popup).toBe("popup/index.html");
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/manifest.test.ts`
|
||||
Expected: FAIL because manifest does not include popup / auth permissions
|
||||
|
||||
- [ ] **Step 3: Implement the minimal popup view**
|
||||
|
||||
Create `src/popup/view.ts` with rendering helpers:
|
||||
|
||||
```ts
|
||||
export function renderLoggedOut(root: HTMLElement, error?: string | null) {
|
||||
root.innerHTML = `
|
||||
<section data-popup-state="logged-out">
|
||||
<h1>Star Chart Search Enhancer</h1>
|
||||
<p>登录后才能使用星图增强功能</p>
|
||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
Also add `renderLoggedIn()` and `renderDevPanel()` helpers.
|
||||
|
||||
- [ ] **Step 4: Implement the minimal popup bootstrap**
|
||||
|
||||
Create `src/popup/index.ts` with:
|
||||
|
||||
```ts
|
||||
import { renderLoggedIn, renderLoggedOut } from "./view";
|
||||
|
||||
export async function bootPopup(options = {}) {
|
||||
const currentDocument = options.document ?? document;
|
||||
const root = currentDocument.querySelector("#app") as HTMLElement | null;
|
||||
if (!root) {
|
||||
throw new Error("popup root #app is required");
|
||||
}
|
||||
const sendMessage =
|
||||
options.sendMessage ??
|
||||
((message: unknown) =>
|
||||
chrome.runtime.sendMessage(message) as Promise<unknown>);
|
||||
|
||||
const stateResponse = await sendMessage({ type: "auth:get-state" });
|
||||
if (!stateResponse.ok || stateResponse.type !== "auth:state") {
|
||||
renderLoggedOut(root, "认证状态读取失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stateResponse.value.isAuthenticated) {
|
||||
renderLoggedOut(root, stateResponse.value.lastError);
|
||||
root.querySelector("[data-popup-sign-in='button']")?.addEventListener("click", async () => {
|
||||
await sendMessage({ type: "auth:sign-in" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoggedIn(root, stateResponse.value);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/popup/index.html`:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Chart Search Enhancer</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update build and manifest**
|
||||
|
||||
Modify `scripts/build.mjs` to:
|
||||
|
||||
- build `src/popup/index.ts` into `dist/popup/index.js`
|
||||
- create `dist/popup`
|
||||
- copy `src/popup/index.html` into `dist/popup/index.html`
|
||||
|
||||
Modify `src/manifest.json` to:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["downloads", "identity", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preserve current background and content script entries.
|
||||
|
||||
- [ ] **Step 6: Run popup and manifest tests**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the popup and manifest slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup src/manifest.json scripts/build.mjs tests/popup-entry.test.ts tests/manifest.test.ts
|
||||
git commit -m "feat: add popup auth entry and manifest wiring"
|
||||
```
|
||||
|
||||
## Task 4: Content Auth Gating
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/auth-gate.ts`
|
||||
- Modify: `src/content/index.ts`
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-auth-gating.test.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing content gating test**
|
||||
|
||||
Create `tests/market-auth-gating.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { bootContentScript } from "../src/content/index";
|
||||
|
||||
describe("market-auth-gating", () => {
|
||||
test("shows a login gate instead of booting the market controller when unauthenticated", async () => {
|
||||
document.body.innerHTML = "<div></div>";
|
||||
|
||||
const createMarketController = vi.fn();
|
||||
await bootContentScript({
|
||||
createMarketController,
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: false }
|
||||
})),
|
||||
window
|
||||
});
|
||||
|
||||
expect(createMarketController).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("请先登录插件");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/market-auth-gating.test.ts`
|
||||
Expected: FAIL because `bootContentScript` does not gate on auth yet
|
||||
|
||||
- [ ] **Step 2: Add an integration assertion to the existing content entry tests**
|
||||
|
||||
Modify `tests/market-content-entry.test.ts` to add:
|
||||
|
||||
```ts
|
||||
test("boots the controller only after auth succeeds", async () => {
|
||||
const createMarketController = vi.fn(() => ({ ready: Promise.resolve() }));
|
||||
await bootContentScript({
|
||||
createMarketController,
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: { isAuthenticated: true }
|
||||
})),
|
||||
window
|
||||
});
|
||||
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL until `bootContentScript` accepts the new auth dependency
|
||||
|
||||
- [ ] **Step 3: Implement the auth gate UI**
|
||||
|
||||
Create `src/content/market/auth-gate.ts`:
|
||||
|
||||
```ts
|
||||
export function renderMarketAuthGate(document: Document): HTMLElement {
|
||||
const root = document.createElement("section");
|
||||
root.dataset.marketAuthGate = "root";
|
||||
root.innerHTML = `
|
||||
<strong>请先登录插件</strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
root.querySelector("[data-market-auth-help='button']")?.addEventListener("click", () => {
|
||||
window.alert("请点击浏览器工具栏中的扩展图标完成登录");
|
||||
});
|
||||
document.body.prepend(root);
|
||||
return root;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate `bootContentScript()` before controller startup**
|
||||
|
||||
Modify `src/content/index.ts` to:
|
||||
|
||||
- accept an injectable `sendAuthMessage`
|
||||
- call background with `{ type: "auth:get-state" }`
|
||||
- if unauthenticated, render the gate and return `{ ready: Promise.resolve() }` or `null`
|
||||
- only call `createMarketController()` after auth succeeds
|
||||
|
||||
Suggested helper:
|
||||
|
||||
```ts
|
||||
async function readAuthState(
|
||||
sendMessage: (message: unknown) => Promise<unknown>
|
||||
): Promise<AuthStateValue | null> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
return response.ok && response.type === "auth:state" ? response.value : null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Keep market controller auth-agnostic**
|
||||
|
||||
Only touch `src/content/market/index.ts` if the existing controller assumes it always owns the toolbar root. Prefer to keep auth gating in `src/content/index.ts`; only add defensive guards if the current toolbar boot sequence conflicts with the auth gate DOM.
|
||||
|
||||
- [ ] **Step 6: Run content gating tests**
|
||||
|
||||
Run: `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the auth gate slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/index.ts src/content/market/auth-gate.ts src/content/market/index.ts tests/market-auth-gating.test.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: gate market tools behind authentication"
|
||||
```
|
||||
|
||||
## Task 5: Dev Diagnostics, Build Verification, And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `src/popup/index.ts`
|
||||
- Modify: `src/background/auth/controller.ts`
|
||||
- Modify: `README.md`
|
||||
- Modify: `externaldocs/2026-04-21-logto-auth-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup dev panel test**
|
||||
|
||||
Extend `tests/popup-entry.test.ts` with:
|
||||
|
||||
```ts
|
||||
test("renders the dev auth panel when enabled", async () => {
|
||||
document.body.innerHTML = "<main id='app'></main>";
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
accessTokenExpiresAt: 1700000000000,
|
||||
isAuthenticated: true,
|
||||
resource: "https://api.example.test",
|
||||
scopes: ["openid", "profile"],
|
||||
tokenAvailable: true,
|
||||
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("resource");
|
||||
expect(document.body.textContent).toContain("token");
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: FAIL because the dev panel is not rendered yet
|
||||
|
||||
- [ ] **Step 2: Implement minimal diagnostics**
|
||||
|
||||
Update popup rendering to show when `enableDevAuthPanel` is true:
|
||||
|
||||
- `resource`
|
||||
- `scopes`
|
||||
- `tokenAvailable`
|
||||
- `accessTokenExpiresAt`
|
||||
- `lastError`
|
||||
|
||||
Update background state mapping to return `tokenAvailable` and `accessTokenExpiresAt` placeholders even when the exact expiry cannot be resolved yet:
|
||||
|
||||
```ts
|
||||
return {
|
||||
accessTokenExpiresAt: null,
|
||||
isAuthenticated: true,
|
||||
resource: config.apiResource,
|
||||
scopes: config.scopes,
|
||||
tokenAvailable: true,
|
||||
userInfo
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the popup suite and the full test suite**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS across existing and new tests
|
||||
|
||||
- [ ] **Step 4: Run the build and verify output structure**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected:
|
||||
|
||||
- `dist/manifest.json`
|
||||
- `dist/background/index.js`
|
||||
- `dist/content/index.js`
|
||||
- `dist/content/market-page-bridge.js`
|
||||
- `dist/popup/index.html`
|
||||
- `dist/popup/index.js`
|
||||
|
||||
- [ ] **Step 5: Update docs**
|
||||
|
||||
Update `README.md` with:
|
||||
|
||||
- how to load the popup
|
||||
- what placeholder Logto config values must be replaced
|
||||
- what “unauthenticated” looks like on the market page
|
||||
|
||||
Update `externaldocs/2026-04-21-logto-auth-design.md` only if implementation choices diverge from the design. Also fix any current encoding artifacts in that spec while editing.
|
||||
|
||||
- [ ] **Step 6: Commit the verification and docs slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup/view.ts src/popup/index.ts src/background/auth/controller.ts README.md externaldocs/2026-04-21-logto-auth-design.md
|
||||
git commit -m "docs: document logto auth flow and diagnostics"
|
||||
```
|
||||
|
||||
## Final Verification Checklist
|
||||
|
||||
- [ ] `npm test -- tests/auth-config.test.ts tests/auth-messages.test.ts`
|
||||
- [ ] `npm test -- tests/background-auth-controller.test.ts tests/background-index.test.ts`
|
||||
- [ ] `npm test -- tests/popup-entry.test.ts tests/manifest.test.ts`
|
||||
- [ ] `npm test -- tests/market-auth-gating.test.ts tests/market-content-entry.test.ts`
|
||||
- [ ] `npm test`
|
||||
- [ ] `npm run build`
|
||||
- [ ] Load `dist/` in `chrome://extensions`
|
||||
- [ ] Verify popup shows logged-out state before configuration is real
|
||||
- [ ] Verify market page shows auth gate when unauthenticated
|
||||
|
||||
## Open Inputs Still Required From The User
|
||||
|
||||
- Real `logtoEndpoint`
|
||||
- Real `appId`
|
||||
- Real `apiResource`
|
||||
- Any extra scopes beyond `openid profile offline_access`
|
||||
- Whether dev auth panel should be hard-disabled in production builds or controlled by config only
|
||||
@ -1,710 +0,0 @@
|
||||
# Logto Protected API Mock Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a reusable protected-API client that pulls a Logto access token from the extension background, sends `Authorization: Bearer <token>` to a local mock API, and exposes a dev-only popup flow to verify the end-to-end request path before a real backend exists.
|
||||
|
||||
**Architecture:** Keep Logto token ownership in the background and add a small reusable client in `src/shared` so both popup and future content code can call protected APIs the same way. Add a tiny local Node mock server that only checks for a Bearer header and returns fixed JSON. Use popup dev UI as the manual test entrypoint because it already has an authenticated extension context and avoids coupling the mock flow to the Xingtu page DOM.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, Node HTTP server, tsup, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/shared/protected-api-client.ts`
|
||||
- `scripts/mock-protected-api.mjs`
|
||||
- `tests/protected-api-client.test.ts`
|
||||
- `tests/mock-protected-api.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `src/popup/index.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/background-index.test.ts`
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/shared/protected-api-client.ts`: reusable client that asks background for a token, injects the Bearer header, and normalizes success / unauthorized / missing-token errors.
|
||||
- `scripts/mock-protected-api.mjs`: minimal local server with one protected endpoint and a CLI start mode.
|
||||
- `src/popup/view.ts`: add a dev-only “测试受保护接口” button and a response/error output area.
|
||||
- `src/popup/index.ts`: wire the dev button to the protected API client and refresh the popup state after calls.
|
||||
- `tests/protected-api-client.test.ts`: lock down token injection and error behavior.
|
||||
- `tests/mock-protected-api.test.ts`: verify the mock server authorizes Bearer requests and rejects missing headers.
|
||||
- `tests/popup-entry.test.ts`: verify popup dev action triggers the protected client and renders results.
|
||||
|
||||
## Task 1: Lock Down The Existing Token Message Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
- Modify: `src/background/index.ts` only if the test exposes a gap
|
||||
|
||||
- [ ] **Step 1: Write the failing background token response test**
|
||||
|
||||
Add a case to `tests/background-index.test.ts`:
|
||||
|
||||
```ts
|
||||
test("responds to auth:get-access-token with the current token", async () => {
|
||||
const listeners: Array<
|
||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||
> = [];
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
registerBackgroundMessageHandler(
|
||||
{
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
authController: {
|
||||
getAccessToken: vi.fn(async () => "test-access-token"),
|
||||
getAuthState: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = listeners[0]({ type: "auth:get-access-token" }, {}, sendResponse);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(sendResponse).toHaveBeenCalledWith({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "test-access-token" }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused background test**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
|
||||
Expected:
|
||||
- If it fails because token handling is broken or missing, continue to Step 3.
|
||||
- If it passes immediately, keep the test as a regression and skip production changes for this task.
|
||||
|
||||
- [ ] **Step 3: Make the minimal background fix if needed**
|
||||
|
||||
Ensure `src/background/index.ts` handles:
|
||||
|
||||
```ts
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: await authController.getAccessToken()
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run the background test and confirm green**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the token-contract slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add tests/background-index.test.ts src/background/index.ts
|
||||
git commit -m "test: cover auth access token responses"
|
||||
```
|
||||
|
||||
## Task 2: Build The Reusable Protected API Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/protected-api-client.ts`
|
||||
- Create: `tests/protected-api-client.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing protected client tests**
|
||||
|
||||
Create `tests/protected-api-client.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createProtectedApiClient } from "../src/shared/protected-api-client";
|
||||
|
||||
describe("protected-api-client", () => {
|
||||
test("requests a token before calling the protected endpoint", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true })
|
||||
}));
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.loadProtectedMockData();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/protected",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123"
|
||||
}),
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws before fetch when the token is unavailable", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: false,
|
||||
type: "auth:error",
|
||||
error: "token missing"
|
||||
}));
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws an authorization error on 401", async () => {
|
||||
const client = createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, error: "unauthorized" })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the protected client test to verify red**
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: FAIL because `createProtectedApiClient` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal protected client**
|
||||
|
||||
Create `src/shared/protected-api-client.ts` with:
|
||||
|
||||
```ts
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createProtectedApiClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
return {
|
||||
async loadProtectedMockData() {
|
||||
const token = await readAccessToken(options.sendMessage);
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/protected", options.baseUrl).toString(),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
method: "GET"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("protected api unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`protected api request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("protected api token unavailable");
|
||||
}
|
||||
return response.value.accessToken;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the protected client test to verify green**
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Refactor only if tests stay green**
|
||||
|
||||
Optional small cleanup only:
|
||||
- extract URL builder helper
|
||||
- extract `buildAuthorizationHeaders(token)` helper if repeated
|
||||
|
||||
Run: `npm test -- tests/protected-api-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the client slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/shared/protected-api-client.ts tests/protected-api-client.test.ts
|
||||
git commit -m "feat: add protected api client"
|
||||
```
|
||||
|
||||
## Task 3: Add A Popup Dev Trigger For Manual Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/popup/index.ts`
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `tests/popup-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing popup dev-flow tests**
|
||||
|
||||
Add cases to `tests/popup-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("renders a protected api test button in the dev panel", async () => {
|
||||
const { bootPopup } = await import("../src/popup/index");
|
||||
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-popup-test-protected-api="button"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("clicking the dev button runs the protected api client and prints the result", async () => {
|
||||
const sendMessage = vi.fn(async (message: { type: string }) => {
|
||||
if (message.type === "auth:get-state") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: true,
|
||||
tokenAvailable: true
|
||||
}
|
||||
};
|
||||
}
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: "abc123"
|
||||
}
|
||||
};
|
||||
}
|
||||
return { ok: true, type: "auth:ack" };
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, message: "authorized" })
|
||||
}));
|
||||
|
||||
const { bootPopup } = await import("../src/popup/index");
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
|
||||
await bootPopup({
|
||||
config: { enableDevAuthPanel: true },
|
||||
document,
|
||||
fetchProtectedApi: () =>
|
||||
createProtectedApiClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
}).loadProtectedMockData(),
|
||||
sendMessage
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-popup-test-protected-api="button"]')
|
||||
?.dispatchEvent(new MouseEvent("click"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(document.body.textContent).toContain("authorized");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the popup tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
|
||||
Expected: FAIL because the popup dev test entrypoint does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal popup UI**
|
||||
|
||||
Update `src/popup/view.ts` so `renderDevPanel()` also renders:
|
||||
|
||||
```html
|
||||
<button type="button" data-popup-test-protected-api="button">
|
||||
测试受保护接口
|
||||
</button>
|
||||
<pre data-popup-protected-api-result="output"></pre>
|
||||
```
|
||||
|
||||
Add a small helper for setting the output:
|
||||
|
||||
```ts
|
||||
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||
root
|
||||
.querySelector('[data-popup-protected-api-result="output"]')
|
||||
?.replaceChildren(value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the popup button to the protected client**
|
||||
|
||||
Update `src/popup/index.ts`:
|
||||
- inject an optional `fetchProtectedApi` dependency
|
||||
- default it to a `createProtectedApiClient(...)` instance using `sendMessage`
|
||||
- attach a click handler only when the dev panel is enabled and the user is logged in
|
||||
- write either the JSON response or the thrown error message into the output area
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
interface BootPopupOptions {
|
||||
config?: Partial<AuthConfig>;
|
||||
document?: Document;
|
||||
fetchProtectedApi?: () => Promise<unknown>;
|
||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the popup tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/popup-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the popup dev slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/popup/index.ts src/popup/view.ts tests/popup-entry.test.ts
|
||||
git commit -m "feat: add popup protected api dev test"
|
||||
```
|
||||
|
||||
## Task 4: Add The Local Mock Protected API Server
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/mock-protected-api.mjs`
|
||||
- Create: `tests/mock-protected-api.test.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Write the failing mock server tests**
|
||||
|
||||
Create `tests/mock-protected-api.test.ts` with:
|
||||
|
||||
```ts
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
|
||||
|
||||
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
await servers.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("mock-protected-api", () => {
|
||||
test("returns mock data when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
|
||||
headers: {
|
||||
Authorization: "Bearer abc123"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
source: "mock-protected-api"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 401 when the Authorization header is missing", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
servers.push(server);
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "unauthorized"
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the mock server tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: FAIL because the mock server module does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal mock server**
|
||||
|
||||
Create `scripts/mock-protected-api.mjs` with:
|
||||
|
||||
```js
|
||||
import http from "node:http";
|
||||
|
||||
export function createMockProtectedApiServer({ port = 4319 } = {}) {
|
||||
let server;
|
||||
|
||||
return {
|
||||
get baseUrl() {
|
||||
const address = server?.address();
|
||||
const resolvedPort =
|
||||
typeof address === "object" && address ? address.port : port;
|
||||
return `http://127.0.0.1:${resolvedPort}`;
|
||||
},
|
||||
async start() {
|
||||
server = http.createServer((request, response) => {
|
||||
if (request.url !== "/api/mock/protected") {
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "not-found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const isBearer =
|
||||
typeof authHeader === "string" &&
|
||||
authHeader.startsWith("Bearer ") &&
|
||||
authHeader.length > "Bearer ".length;
|
||||
|
||||
if (!isBearer) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-protected-api",
|
||||
message: "authorized",
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
},
|
||||
async close() {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const server = createMockProtectedApiServer();
|
||||
await server.start();
|
||||
console.log(`mock protected api listening on ${server.baseUrl}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add a package script to start the mock API**
|
||||
|
||||
Update `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"mock:protected-api": "node scripts/mock-protected-api.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the mock server tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the mock server slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts package.json
|
||||
git commit -m "feat: add mock protected api server"
|
||||
```
|
||||
|
||||
## Task 5: Document And Verify The End-To-End Local Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing documentation expectation**
|
||||
|
||||
Before editing docs, list the missing verification steps in a scratch note:
|
||||
- how to enable the dev panel
|
||||
- how to start the mock server
|
||||
- how to click the popup test button
|
||||
- what success and failure look like
|
||||
|
||||
Expected: current `README.md` does not explain this flow yet
|
||||
|
||||
- [ ] **Step 2: Add the manual verification section**
|
||||
|
||||
Update `README.md` with a short section:
|
||||
|
||||
```md
|
||||
## Protected API Mock Test
|
||||
|
||||
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
|
||||
2. Run `npm run mock:protected-api`
|
||||
3. Run `npm run build`
|
||||
4. Reload the unpacked extension
|
||||
5. Open the popup and log in
|
||||
6. Click `测试受保护接口`
|
||||
7. Confirm the popup shows a JSON result containing `"source": "mock-protected-api"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the relevant automated tests together**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/background-index.test.ts tests/protected-api-client.test.ts tests/popup-entry.test.ts tests/mock-protected-api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Perform the manual smoke test**
|
||||
|
||||
Run in separate terminals:
|
||||
|
||||
```bash
|
||||
npm run mock:protected-api
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
- terminal 1 prints `mock protected api listening on http://127.0.0.1:4319`
|
||||
- extension loads from `dist/`
|
||||
- popup dev panel can trigger the protected endpoint
|
||||
- success output includes `"message": "authorized"`
|
||||
|
||||
- [ ] **Step 5: Commit the docs and verification slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: add protected api mock verification steps"
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run the full test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes and writes updated assets to `dist/`
|
||||
|
||||
- [ ] Confirm the only functional additions are:
|
||||
- background token response covered by tests
|
||||
- reusable protected API client
|
||||
- popup dev test button and result output
|
||||
- local mock protected API server
|
||||
- updated verification docs
|
||||
@ -1,79 +0,0 @@
|
||||
# Market Backend Metrics CSV Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend CSV export so it appends the six backend metrics already stored on each market record.
|
||||
|
||||
**Architecture:** Keep the existing export flow intact and modify only the CSV column definition layer. Reuse `MarketRecord.backendMetrics` as the sole source for the six new CSV columns so the exported data matches what the plugin already loaded into memory.
|
||||
|
||||
**Tech Stack:** TypeScript, existing market CSV exporter, Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/content/market/csv-exporter.ts`
|
||||
- Append six backend metrics columns after existing CSV columns.
|
||||
- Modify: `tests/csv-exporter.test.ts`
|
||||
- Add failing tests for backend metric headers and values.
|
||||
|
||||
### Task 1: Backend Metrics CSV Columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/csv-exporter.test.ts`
|
||||
- Modify: `src/content/market/csv-exporter.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing CSV exporter tests**
|
||||
|
||||
Add tests for:
|
||||
- the six backend metric headers appended after current columns
|
||||
- backend metric values exported from `record.backendMetrics`
|
||||
- blank cells when `backendMetrics` is absent
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- tests/csv-exporter.test.ts`
|
||||
Expected: FAIL because the exporter does not include backend metric columns yet.
|
||||
|
||||
- [ ] **Step 3: Write the minimal exporter change**
|
||||
|
||||
Append these six columns:
|
||||
- `看后搜率`
|
||||
- `看后搜数`
|
||||
- `新增A3数`
|
||||
- `新增A3率`
|
||||
- `CPA3`
|
||||
- `cp_search`
|
||||
|
||||
Each column reads from:
|
||||
- `record.backendMetrics?.afterViewSearchRate`
|
||||
- `record.backendMetrics?.afterViewSearchCount`
|
||||
- `record.backendMetrics?.a3IncreaseCount`
|
||||
- `record.backendMetrics?.newA3Rate`
|
||||
- `record.backendMetrics?.cpa3`
|
||||
- `record.backendMetrics?.cpSearch`
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- tests/csv-exporter.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
- full test suite passes
|
||||
- build succeeds
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/csv-exporter.ts tests/csv-exporter.test.ts docs/superpowers/specs/2026-04-22-market-backend-metrics-csv-design.md docs/superpowers/plans/2026-04-22-market-backend-metrics-csv.md
|
||||
git commit -m "feat: export backend metrics in csv"
|
||||
```
|
||||
@ -1,258 +0,0 @@
|
||||
# Market Backend Metrics Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Connect the extension to the real backend creator search API and render six backend metrics in a new `秒探指标` column while preserving the two existing Xingtu rate columns.
|
||||
|
||||
**Architecture:** Add a background-owned backend metrics request path that uses the existing Logto access token, then batch-request the visible page's `star_id` values from the content layer and map results back onto per-row UI state. Keep Xingtu-sourced rates untouched and add a second metrics structure for the backend-only values.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 background messaging, existing Logto auth flow, Vitest, tsup
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Add a background message contract for backend metrics search.
|
||||
- Create: `src/shared/backend-metrics-config.ts`
|
||||
- Hold the default backend base URL in one place.
|
||||
- Create: `src/shared/backend-metrics-client.ts`
|
||||
- Build the backend request and map response rows into extension-friendly metric objects.
|
||||
- Modify: `src/background/index.ts`
|
||||
- Handle the new backend metrics runtime message and call the backend client with Logto token.
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Add types for the six backend metrics and merged row state.
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Persist backend metrics, loading, success, missing, and failure states.
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Add one `秒探指标` column and render loading/success/missing/failure states.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Batch-load visible page `star_id` values through background and update row state.
|
||||
- Test: `tests/backend-metrics-client.test.ts`
|
||||
- Verify request payloads and response mapping.
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
- Verify background handles backend metrics requests.
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
- Verify the content controller batches visible `star_id` values and renders the new metrics column.
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
- Verify the new UI cell states.
|
||||
- Modify: `tests/auth-messages.test.ts`
|
||||
- Verify new message guards.
|
||||
|
||||
### Task 1: Shared Backend Metrics Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/backend-metrics-config.ts`
|
||||
- Create: `src/shared/backend-metrics-client.ts`
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Test: `tests/backend-metrics-client.test.ts`
|
||||
- Test: `tests/auth-messages.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing shared tests**
|
||||
|
||||
Add tests for:
|
||||
- default backend base URL export
|
||||
- request body shape using `type: "star_id"`
|
||||
- mapping backend fields:
|
||||
- `avg_after_view_search_rate`
|
||||
- `avg_after_view_search_cnt`
|
||||
- `avg_a3_increase_cnt`
|
||||
- `avg_new_a3_rate`
|
||||
- `cpa3`
|
||||
- `cp_search`
|
||||
- new runtime message guard recognition
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
|
||||
Expected: FAIL because the backend metrics files and message shapes do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal shared implementation**
|
||||
|
||||
Implement:
|
||||
- `DEFAULT_BACKEND_METRICS_BASE_URL`
|
||||
- a backend client that posts to `/api/v1/history/talents/search`
|
||||
- request/response helpers for runtime messaging
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts
|
||||
git commit -m "feat: add backend metrics shared client"
|
||||
```
|
||||
|
||||
### Task 2: Background Search Bridge
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/background/index.ts`
|
||||
- Modify: `tests/background-index.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing background test**
|
||||
|
||||
Add a test for a runtime message like:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "backend-metrics:search",
|
||||
value: {
|
||||
starIds: ["111", "222"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
- background reads the Logto access token
|
||||
- background calls the backend metrics client
|
||||
- background returns `{ ok: true, type: "backend-metrics:result", value: ... }`
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: FAIL because background does not handle the new message.
|
||||
|
||||
- [ ] **Step 3: Write minimal background implementation**
|
||||
|
||||
Add:
|
||||
- a new message type guard branch
|
||||
- lazy creation of the backend metrics client
|
||||
- token injection via the existing auth controller
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- tests/background-index.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts tests/background-index.test.ts
|
||||
git commit -m "feat: wire backend metrics search in background"
|
||||
```
|
||||
|
||||
### Task 3: Row State and DOM Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing DOM rendering tests**
|
||||
|
||||
Add tests for:
|
||||
- header contains `秒探指标`
|
||||
- success state renders six metric labels and values
|
||||
- loading state renders `加载中...`
|
||||
- missing state renders `暂无数据`
|
||||
- failed state renders `加载失败`
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: FAIL because the new column and states are not implemented.
|
||||
|
||||
- [ ] **Step 3: Write minimal row-state and rendering implementation**
|
||||
|
||||
Add:
|
||||
- backend metrics types
|
||||
- store methods for backend metrics status transitions
|
||||
- DOM helpers to insert and render the compact metrics panel
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/types.ts src/content/market/result-store.ts src/content/market/dom-sync.ts tests/market-dom-sync.test.ts
|
||||
git commit -m "feat: render backend metrics column"
|
||||
```
|
||||
|
||||
### Task 4: Content Batch Loading
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing content integration tests**
|
||||
|
||||
Add tests for:
|
||||
- collecting visible page `authorId` values as `star_id[]`
|
||||
- sending one background request for the page instead of one request per row
|
||||
- mapping result rows by `star_id`
|
||||
- rendering `暂无数据` when a row is absent from backend response
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: FAIL because the content controller still uses per-row Xingtu loading only.
|
||||
|
||||
- [ ] **Step 3: Write minimal content implementation**
|
||||
|
||||
Change the controller so it:
|
||||
- leaves old Xingtu rates logic intact
|
||||
- batches backend metrics requests per visible page
|
||||
- merges backend results into the store by `authorId`
|
||||
- updates the new metrics cell state
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: batch load backend metrics for market rows"
|
||||
```
|
||||
|
||||
### Task 5: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify only if needed based on failures
|
||||
|
||||
- [ ] **Step 1: Run focused backend metrics test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run production build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: PASS and updated assets in `dist/`
|
||||
|
||||
- [ ] **Step 4: Manual verification checklist**
|
||||
|
||||
Verify in the extension:
|
||||
- popup login still works
|
||||
- current page still shows old two Xingtu rate columns
|
||||
- new `秒探指标` column appears
|
||||
- rows show loading before data arrives
|
||||
- success rows show six backend metrics
|
||||
- unmatched rows show `暂无数据`
|
||||
- backend/network issues show `加载失败`
|
||||
|
||||
- [ ] **Step 5: Final commit**
|
||||
|
||||
```bash
|
||||
git add src/background/index.ts src/content/market/index.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts src/shared/backend-metrics-config.ts src/shared/backend-metrics-client.ts src/shared/auth-messages.ts tests/backend-metrics-client.test.ts tests/auth-messages.test.ts tests/background-index.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add backend metrics to market plugin"
|
||||
```
|
||||
@ -1,697 +0,0 @@
|
||||
# Market Batch Submit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone `提交批次` toolbar action that collects the currently selected export range of Xingtu creators, prompts for a batch name, builds a batch payload with Logto user identity plus creator IDs, and submits the batch to a protected API endpoint with a Bearer token.
|
||||
|
||||
**Architecture:** Reuse the existing export-range collection path so multi-page batch submission behaves exactly like CSV export. Keep responsibilities split: toolbar UI emits a submit intent, a batch-payload module assembles the request body, and a protected batch-submit client owns token injection plus POST behavior. For first-pass verification, extend the local mock server with a batch endpoint that echoes the submitted payload.
|
||||
|
||||
**Tech Stack:** Chrome MV3, TypeScript, Vitest, tsup, Node HTTP server, `@logto/chrome-extension`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
- `src/content/market/batch-payload.ts`
|
||||
- `src/shared/batch-submit-client.ts`
|
||||
- `tests/batch-payload.test.ts`
|
||||
- `tests/batch-submit-client.test.ts`
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- `src/content/market/plugin-toolbar.ts`
|
||||
- `src/content/market/index.ts`
|
||||
- `src/shared/auth-messages.ts`
|
||||
- `src/background/auth/state.ts`
|
||||
- `src/popup/view.ts`
|
||||
- `scripts/mock-protected-api.mjs`
|
||||
- `tests/market-content-entry.test.ts`
|
||||
- `tests/popup-entry.test.ts`
|
||||
- `tests/mock-protected-api.test.ts`
|
||||
- `README.md`
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- `src/content/market/plugin-toolbar.ts`: add a dedicated submit button and include it in busy-state handling.
|
||||
- `src/content/market/index.ts`: orchestrate prompt input, range collection reuse, payload creation, and batch submission.
|
||||
- `src/content/market/batch-payload.ts`: create the batch payload from batch name, timestamp, auth state, and market records.
|
||||
- `src/shared/batch-submit-client.ts`: POST JSON to the protected batch endpoint with `Authorization: Bearer <token>`.
|
||||
- `src/shared/auth-messages.ts` and `src/background/auth/state.ts`: expose stable access to Logto user `sub` and display name already present in auth state.
|
||||
- `scripts/mock-protected-api.mjs`: add a local `/api/mock/batches` endpoint that validates Bearer auth and echoes the payload.
|
||||
|
||||
## Task 1: Add The Batch Payload Builder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/batch-payload.ts`
|
||||
- Create: `tests/batch-payload.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing batch payload tests**
|
||||
|
||||
Create `tests/batch-payload.test.ts` with cases for:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createBatchPayload } from "../src/content/market/batch-payload";
|
||||
|
||||
describe("batch-payload", () => {
|
||||
test("builds a batch id from the batch name and timestamp", () => {
|
||||
const payload = createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿",
|
||||
sub: "p7pdhhtde8kj"
|
||||
}
|
||||
},
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [
|
||||
{ authorId: "111", authorName: "达人A", status: "success" },
|
||||
{ authorId: "222", authorName: "达人B", status: "success" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
authors: [
|
||||
{ authorId: "111", authorName: "达人A" },
|
||||
{ authorId: "222", authorName: "达人B" }
|
||||
],
|
||||
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||
batchName: "618达人筛选第一批",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when the user id is unavailable", () => {
|
||||
expect(() =>
|
||||
createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿"
|
||||
}
|
||||
},
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
|
||||
})
|
||||
).toThrow(/user/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the batch payload test to verify red**
|
||||
|
||||
Run: `npm test -- tests/batch-payload.test.ts`
|
||||
|
||||
Expected: FAIL because `createBatchPayload` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal batch payload builder**
|
||||
|
||||
Create `src/content/market/batch-payload.ts` with:
|
||||
|
||||
```ts
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export interface BatchPayload {
|
||||
authors: Array<{
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
}>;
|
||||
batchId: string;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
creatorName: string;
|
||||
logtoUserId: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
export function createBatchPayload(options: {
|
||||
authState: AuthStateValue;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
records: MarketRecord[];
|
||||
}): BatchPayload {
|
||||
const logtoUserId = options.authState.userInfo?.sub?.trim();
|
||||
if (!logtoUserId) {
|
||||
throw new Error("batch submit user id unavailable");
|
||||
}
|
||||
|
||||
const resource = options.authState.resource?.trim();
|
||||
if (!resource) {
|
||||
throw new Error("batch submit resource unavailable");
|
||||
}
|
||||
|
||||
const batchName = options.batchName.trim();
|
||||
if (!batchName) {
|
||||
throw new Error("batch submit batch name is required");
|
||||
}
|
||||
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName
|
||||
})),
|
||||
batchId: `${batchName}-${options.createdAt}`,
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
options.authState.userInfo?.name ??
|
||||
options.authState.userInfo?.username ??
|
||||
logtoUserId,
|
||||
logtoUserId,
|
||||
resource
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the batch payload test to verify green**
|
||||
|
||||
Run: `npm test -- tests/batch-payload.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the payload builder slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/batch-payload.ts tests/batch-payload.test.ts
|
||||
git commit -m "feat: add batch payload builder"
|
||||
```
|
||||
|
||||
## Task 2: Build The Protected Batch Submit Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/batch-submit-client.ts`
|
||||
- Create: `tests/batch-submit-client.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing batch submit client tests**
|
||||
|
||||
Create `tests/batch-submit-client.test.ts` with:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||
|
||||
describe("batch-submit-client", () => {
|
||||
test("posts the batch payload with a Bearer token", async () => {
|
||||
const sendMessage = vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}));
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, acceptedCount: 2 })
|
||||
}));
|
||||
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl,
|
||||
sendMessage
|
||||
});
|
||||
|
||||
await client.submitBatch({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:4319/api/mock/batches",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(
|
||||
expect.objectContaining({
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z"
|
||||
})
|
||||
),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
method: "POST"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws on unauthorized responses", async () => {
|
||||
const client = createBatchSubmitClient({
|
||||
baseUrl: "http://127.0.0.1:4319",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, error: "unauthorized" })
|
||||
})),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: { accessToken: "abc123" }
|
||||
}))
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.submitBatch({
|
||||
authors: [],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
})
|
||||
).rejects.toThrow(/unauthorized/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the batch submit client test to verify red**
|
||||
|
||||
Run: `npm test -- tests/batch-submit-client.test.ts`
|
||||
|
||||
Expected: FAIL because `createBatchSubmitClient` does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal batch submit client**
|
||||
|
||||
Create `src/shared/batch-submit-client.ts` with:
|
||||
|
||||
```ts
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
import type { BatchPayload } from "../content/market/batch-payload";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createBatchSubmitClient(options: {
|
||||
baseUrl: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
return {
|
||||
async submitBatch(payload: BatchPayload) {
|
||||
const token = await readAccessToken(options.sendMessage);
|
||||
const response = await fetchImpl(
|
||||
new URL("/api/mock/batches", options.baseUrl).toString(),
|
||||
{
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("batch submit unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`batch submit failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("batch submit token unavailable");
|
||||
}
|
||||
return response.value.accessToken;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the batch submit client test to verify green**
|
||||
|
||||
Run: `npm test -- tests/batch-submit-client.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the batch submit client slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/shared/batch-submit-client.ts tests/batch-submit-client.test.ts
|
||||
git commit -m "feat: add batch submit client"
|
||||
```
|
||||
|
||||
## Task 3: Extend The Toolbar With A Batch Submit Action
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing toolbar tests**
|
||||
|
||||
Add tests to `tests/market-content-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("renders a batch submit button in the toolbar", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: true,
|
||||
rates: {
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%",
|
||||
singleVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}),
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
|
||||
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
Also add a test that `setToolbarBusyState()` disables the batch submit button.
|
||||
|
||||
- [ ] **Step 2: Run the focused market toolbar tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: FAIL because the batch submit button does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal toolbar button**
|
||||
|
||||
Update `src/content/market/plugin-toolbar.ts`:
|
||||
|
||||
- extend `PluginToolbarHandlers` with `onSubmitBatch()`
|
||||
- extend `PluginToolbarDom` with `batchSubmitButton: HTMLButtonElement`
|
||||
- create a new button:
|
||||
|
||||
```ts
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
```
|
||||
|
||||
- append it next to `exportButton`
|
||||
- wire its click handler to `handlers.onSubmitBatch()`
|
||||
- include it in `readToolbarDom()` and `setToolbarBusyState()`
|
||||
|
||||
- [ ] **Step 4: Re-run the focused market toolbar tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the toolbar slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add batch submit toolbar action"
|
||||
```
|
||||
|
||||
## Task 4: Wire Batch Submission Into The Market Controller
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/shared/auth-messages.ts`
|
||||
- Modify: `src/background/auth/state.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Add tests to `tests/market-content-entry.test.ts` for:
|
||||
|
||||
```ts
|
||||
test("prompts for a batch name before submitting the current range", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const prompt = vi.fn(() => "618达人筛选第一批");
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: true,
|
||||
rates: {
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%",
|
||||
singleVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}),
|
||||
promptBatchName: prompt,
|
||||
submitBatch,
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
(document.querySelector('[data-plugin-batch-submit="button"]') as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prompt).toHaveBeenCalled();
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
batchName: "618达人筛选第一批",
|
||||
logtoUserId: "p7pdhhtde8kj"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("shows an error when the batch name is blank", async () => {
|
||||
// same setup, but prompt returns " "
|
||||
// expect toolbar status to contain "请输入批次名称"
|
||||
});
|
||||
|
||||
test("does nothing when the prompt is cancelled", async () => {
|
||||
// prompt returns null
|
||||
// submitBatch is not called
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused market controller tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: FAIL because batch submission wiring does not exist yet
|
||||
|
||||
- [ ] **Step 3: Add the minimal market controller wiring**
|
||||
|
||||
Update `src/content/market/index.ts`:
|
||||
|
||||
- add optional dependencies:
|
||||
|
||||
```ts
|
||||
getAuthState?: () => Promise<AuthStateValue>;
|
||||
promptBatchName?: () => string | null;
|
||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||
```
|
||||
|
||||
- default `getAuthState` to a `chrome.runtime.sendMessage({ type: "auth:get-state" })` wrapper
|
||||
- default `promptBatchName` to `() => window.prompt("请输入批次名称")`
|
||||
- default `submitBatch` to `createBatchSubmitClient(...).submitBatch`
|
||||
- add `onSubmitBatch` handler in `ensurePluginToolbar(...)`
|
||||
- behavior:
|
||||
- read target via `readToolbarExportTarget()`
|
||||
- prompt for batch name
|
||||
- cancel on `null`
|
||||
- reject blank names with `setToolbarExportStatus(toolbar, "请输入批次名称")`
|
||||
- collect records via existing `exportRecords(target)`
|
||||
- read auth state
|
||||
- build payload with `createBatchPayload(...)`
|
||||
- call `submitBatch(payload)`
|
||||
- show `批次提交成功` on success
|
||||
- show thrown message or `批次提交失败,请稍后重试` on failure
|
||||
|
||||
- [ ] **Step 4: Extend auth state only if the tests expose a gap**
|
||||
|
||||
If tests reveal missing fields, ensure `src/background/auth/state.ts` continues exposing:
|
||||
- `userInfo.sub`
|
||||
- `userInfo.name`
|
||||
- `resource`
|
||||
|
||||
If `src/shared/auth-messages.ts` needs typing support, update only types, not message kinds.
|
||||
|
||||
- [ ] **Step 5: Re-run the focused market controller tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the market controller slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts src/shared/auth-messages.ts src/background/auth/state.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: wire market batch submission flow"
|
||||
```
|
||||
|
||||
## Task 5: Extend The Mock Server And Popup Debugging
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/mock-protected-api.mjs`
|
||||
- Modify: `tests/mock-protected-api.test.ts`
|
||||
- Modify: `src/popup/view.ts`
|
||||
- Modify: `tests/popup-entry.test.ts` only if needed for debug output wording
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing mock batch endpoint tests**
|
||||
|
||||
Add cases to `tests/mock-protected-api.test.ts`:
|
||||
|
||||
```ts
|
||||
test("accepts a batch payload when a Bearer token is present", async () => {
|
||||
const server = createMockProtectedApiServer({ port: 0 });
|
||||
await server.start();
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/api/mock/batches`, {
|
||||
body: JSON.stringify({
|
||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||
batchName: "批次A",
|
||||
createdAt: "2026-04-22T12:30:00.000Z",
|
||||
creatorName: "王少卿",
|
||||
logtoUserId: "p7pdhhtde8kj",
|
||||
resource: "https://talent-search.intelligrow.cn"
|
||||
}),
|
||||
headers: {
|
||||
Authorization: "Bearer abc123",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
acceptedCount: 1,
|
||||
ok: true,
|
||||
source: "mock-batch-submit"
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the mock server tests to verify red**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: FAIL because the batch endpoint does not exist yet
|
||||
|
||||
- [ ] **Step 3: Implement the minimal mock batch endpoint**
|
||||
|
||||
Update `scripts/mock-protected-api.mjs`:
|
||||
|
||||
- keep the existing `/api/mock/protected`
|
||||
- add `/api/mock/batches` for `POST`
|
||||
- require the same Bearer header validation
|
||||
- read the JSON body and return:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "mock-batch-submit",
|
||||
"acceptedCount": 1,
|
||||
"batchId": "<echoed batch id>"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the docs**
|
||||
|
||||
Add to `README.md`:
|
||||
- how to trigger `提交批次`
|
||||
- how the prompt should be filled
|
||||
- expected mock response
|
||||
|
||||
- [ ] **Step 5: Re-run the mock server tests to verify green**
|
||||
|
||||
Run: `npm test -- tests/mock-protected-api.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the mock batch slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add scripts/mock-protected-api.mjs tests/mock-protected-api.test.ts README.md
|
||||
git commit -m "feat: add mock batch submit endpoint"
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run the focused batch feature tests:
|
||||
|
||||
```bash
|
||||
npm test -- tests/auth-config.test.ts tests/batch-payload.test.ts tests/batch-submit-client.test.ts tests/mock-protected-api.test.ts tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the full test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes and updates `dist/`
|
||||
|
||||
- [ ] Perform the manual smoke test:
|
||||
|
||||
1. Start `npm run mock:protected-api`
|
||||
2. Reload the unpacked extension from `dist/`
|
||||
3. Log in through the popup
|
||||
4. Open the Xingtu market page
|
||||
5. Choose an export range
|
||||
6. Click `提交批次`
|
||||
7. Enter a batch name in `prompt()`
|
||||
8. Confirm success text appears
|
||||
9. Verify the mock response contains the batch id and accepted creator count
|
||||
@ -1,61 +0,0 @@
|
||||
# Market Scrollable Plugin Columns Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move plugin-generated market columns out of the right sticky area and into the horizontally scrollable middle area.
|
||||
|
||||
**Architecture:** Keep Xingtu's native left and right sticky sections intact. Add a dedicated non-sticky plugin section for the injected columns and continue rendering row state through the existing `MarketRowDom` abstraction.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, jsdom
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Intended Layout In Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/market-dom-sync.test.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the layout boundary**
|
||||
|
||||
Add assertions that:
|
||||
- the right sticky header/body widths remain native after plugin columns are added
|
||||
- plugin columns live in a dedicated non-sticky section instead of the right sticky section
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 3: Implement the minimal layout changes**
|
||||
|
||||
Update the div-grid sync path so plugin columns are inserted into a separate scrollable section.
|
||||
|
||||
- [ ] **Step 4: Re-run the same tests**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/market-dom-sync.test.ts tests/market-content-entry.test.ts src/content/market/dom-sync.ts
|
||||
git commit -m "feat: move market plugin columns into scrollable section"
|
||||
```
|
||||
|
||||
### Task 2: Verify No Export Regression
|
||||
|
||||
**Files:**
|
||||
- Verify only: `src/content/market/index.ts`
|
||||
- Verify only: `src/content/market/export-range-controller.ts`
|
||||
- Verify only: `tests/full-scan-controller.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run export-related regression tests**
|
||||
|
||||
Run: `npm test -- tests/full-scan-controller.test.ts`
|
||||
|
||||
- [ ] **Step 2: Run build**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
- [ ] **Step 3: Report any unrelated failures separately**
|
||||
|
||||
Do not broaden the scope unless a new failure is caused by the layout change.
|
||||
@ -1,161 +0,0 @@
|
||||
# Market Native Action Bar Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move the plugin export actions into Xingtu's native market action row and remove the old top toolbar and unused filter/sort controls.
|
||||
|
||||
**Architecture:** Keep the existing export and batch-submit business logic, but replace the toolbar mount point and DOM shape. The new toolbar becomes a small inline action bar inserted into the native button row beside `自定义指标` and `导出`, while the controller only depends on export-range and action buttons.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, jsdom/Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
- Replace the top toolbar with an inline native-style action bar.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Remove filter/sort toolbar dependencies.
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
- Update toolbar assertions to the new placement and reduced control set.
|
||||
|
||||
### Task 1: Lock The New Toolbar Shape In Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing toolbar placement tests**
|
||||
|
||||
Add tests that assert:
|
||||
- the plugin toolbar is inserted next to the native `自定义指标` / `导出` action row
|
||||
- the toolbar no longer renders filter/sort controls
|
||||
- the toolbar still renders export range, custom page input, export button, batch button, and status text
|
||||
|
||||
- [ ] **Step 2: Run the focused test to verify it fails**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row"`
|
||||
Expected: FAIL because the toolbar still prepends to `document.body` and still contains filter/sort controls.
|
||||
|
||||
- [ ] **Step 3: Add a failing busy-state regression test**
|
||||
|
||||
Assert that during export:
|
||||
- export button is disabled
|
||||
- batch button is disabled
|
||||
- export range select is disabled
|
||||
- custom page input is disabled when visible
|
||||
|
||||
- [ ] **Step 4: Run the focused busy-state test**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "exporting all pages disables the native action bar controls during the task"`
|
||||
Expected: FAIL only if the new toolbar structure breaks existing busy-state selectors.
|
||||
|
||||
### Task 2: Implement The Native Action Bar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||
|
||||
- [ ] **Step 1: Replace the toolbar mount strategy**
|
||||
|
||||
Implement a helper that finds the native action row containing `自定义指标` and `导出`, then inserts the plugin root into that row.
|
||||
|
||||
- [ ] **Step 2: Replace the toolbar DOM structure**
|
||||
|
||||
Create only:
|
||||
- export range select
|
||||
- custom pages input
|
||||
- export button
|
||||
- batch submit button
|
||||
- export status text
|
||||
|
||||
Remove creation and lookup of:
|
||||
- filter inputs
|
||||
- filter button
|
||||
- sort field select
|
||||
- sort direction select
|
||||
- sort button
|
||||
|
||||
- [ ] **Step 3: Apply native-style button and inline layout styling**
|
||||
|
||||
Use lightweight inline styles / class reuse so the plugin controls visually align with the native button row.
|
||||
|
||||
- [ ] **Step 4: Keep custom-range visibility logic working**
|
||||
|
||||
Preserve:
|
||||
- `current`
|
||||
- `first-5`
|
||||
- `first-10`
|
||||
- `all`
|
||||
- `custom`
|
||||
|
||||
When `custom` is selected, show the input; otherwise hide it.
|
||||
|
||||
- [ ] **Step 5: Run the focused tests to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row|exporting all pages disables the native action bar controls during the task"
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Remove Toolbar Filter/Sort Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Remove toolbar filter/sort read paths**
|
||||
|
||||
Delete the toolbar handlers and state reads that depend on removed controls.
|
||||
|
||||
- [ ] **Step 2: Keep export and batch submission behavior intact**
|
||||
|
||||
Ensure:
|
||||
- export still reads the selected range
|
||||
- batch submit still reads the selected range
|
||||
- status text still updates during progress and completion
|
||||
|
||||
- [ ] **Step 3: Update affected tests**
|
||||
|
||||
Adjust tests that previously asserted filter/sort button disabled state so they assert only the remaining controls.
|
||||
|
||||
- [ ] **Step 4: Run focused regression tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/market-content-entry.test.ts -t "custom export range blocks invalid page counts|prompts for a batch name before submitting the current range|exporting all pages disables the native action bar controls during the task"
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only: `src/content/market/plugin-toolbar.ts`
|
||||
- Verify only: `src/content/market/index.ts`
|
||||
- Verify only: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run the targeted market content tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/market-content-entry.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts src/content/market/index.ts tests/market-content-entry.test.ts docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md docs/superpowers/plans/2026-04-23-market-native-action-bar.md
|
||||
git commit -m "feat: move market actions into native action bar"
|
||||
```
|
||||
@ -1,181 +0,0 @@
|
||||
# Market Selection Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add cross-page creator selection checkboxes so CSV export and batch submission operate on selected creators within the chosen export range, with fallback to the full export range when no selected creators are present in that range.
|
||||
|
||||
**Architecture:** Extend the market table DOM sync layer with a leading checkbox column and a current-page header select-all control, then keep selection state in the content controller as a `Set<authorId>`. Reuse the existing export-range pipeline by filtering resolved `MarketRecord[]` after range collection and before CSV generation or batch payload creation.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, existing DOM sync helpers, Vitest, jsdom
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Add checkbox column rendering, row checkbox references, header checkbox references, and checkbox state sync helpers.
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Add minimal selection-related DOM typing if needed.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Store selected creator ids, respond to row and header checkbox changes, and filter export / batch records with fallback logic.
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
- Cover injected checkbox header and row controls for both synthetic and div-grid layouts.
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
- Cover per-row selection, current-page select-all, cross-page persistence, export filtering, fallback behavior, and batch submission filtering.
|
||||
|
||||
### Task 1: Checkbox Column DOM
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing DOM tests**
|
||||
|
||||
Add tests for:
|
||||
- synthetic tables inject a leading checkbox header cell and one checkbox cell per row
|
||||
- div-grid tables inject a leading checkbox column and current-page header checkbox
|
||||
- returned table DOM exposes row checkbox elements and the header checkbox
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: FAIL because the checkbox column and DOM references do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal DOM implementation**
|
||||
|
||||
Implement in `src/content/market/dom-sync.ts`:
|
||||
- a leading selection column key
|
||||
- row checkbox creation for synthetic rows
|
||||
- row checkbox column creation for div-grid rows
|
||||
- header checkbox creation for both layouts
|
||||
- `MarketRowDom` and `MarketTableDom` additions for checkbox references
|
||||
|
||||
Keep the new column visually narrow and place it before the author column.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/dom-sync.ts src/content/market/types.ts tests/market-dom-sync.test.ts
|
||||
git commit -m "feat: add market row selection column"
|
||||
```
|
||||
|
||||
### Task 2: Controller Selection State
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Add tests for:
|
||||
- clicking a row checkbox stores that creator selection
|
||||
- selection survives a page change and re-render
|
||||
- header checkbox selects all visible creators on the current page
|
||||
- header checkbox clears all visible creators on the current page
|
||||
- header checkbox becomes indeterminate when only part of the current page is selected
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
|
||||
Expected: FAIL because the controller does not yet track selection or bind checkbox events.
|
||||
|
||||
- [ ] **Step 3: Write minimal controller implementation**
|
||||
|
||||
Implement in `src/content/market/index.ts`:
|
||||
- `selectedAuthorIds: Set<string>`
|
||||
- event listeners for row checkboxes and the header checkbox
|
||||
- current-page checkbox state sync during `applyCurrentView()`
|
||||
- current-page select-all behavior scoped only to visible rows from the current DOM table
|
||||
|
||||
Do not persist selection in `resultStore`. Keep it controller-local and keyed by `authorId`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: track selected market creators"
|
||||
```
|
||||
|
||||
### Task 3: Export and Batch Filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing filtering tests**
|
||||
|
||||
Add tests for:
|
||||
- export uses only selected creators when the resolved export range contains selected creators
|
||||
- export falls back to the full resolved export range when none of the selected creators are inside that resolved range
|
||||
- batch submit uses the same selected-with-fallback record set
|
||||
- cross-page selections only apply when those selected creators are inside the chosen export range
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
|
||||
Expected: FAIL because export and batch submit still use the full resolved range.
|
||||
|
||||
- [ ] **Step 3: Write minimal filtering implementation**
|
||||
|
||||
Implement in `src/content/market/index.ts`:
|
||||
- a helper that filters resolved `MarketRecord[]` by `selectedAuthorIds`
|
||||
- fallback to the original resolved records when the filtered subset is empty
|
||||
- use this helper in both:
|
||||
- CSV export before `buildCsv(records)`
|
||||
- batch submit before `createBatchPayload(...)`
|
||||
|
||||
Keep the CSV exporter and batch payload builder unchanged. Only change the records passed into them.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run focused verification**
|
||||
|
||||
Run:
|
||||
- `npm test -- tests/market-dom-sync.test.ts`
|
||||
- `npm test -- tests/market-content-entry.test.ts`
|
||||
- `npm run build`
|
||||
|
||||
Expected:
|
||||
- market DOM sync tests pass
|
||||
- market content controller tests pass
|
||||
- build exits 0
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: filter market export by selected creators"
|
||||
```
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Scope the header checkbox to the current visible page only.
|
||||
- Recompute header checkbox state after every DOM re-sync and after every selection change.
|
||||
- Apply selection after export-range resolution, not before. This preserves current range semantics.
|
||||
- When no selected creators exist inside the resolved export range, keep current behavior and export / submit the whole resolved range.
|
||||
- Do not modify CSV headers or batch payload shape.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||
- [ ] `npm test -- tests/backend-metrics-client.test.ts tests/batch-submit-client.test.ts tests/background-index.test.ts tests/batch-payload.test.ts`
|
||||
- [ ] `npm run build`
|
||||
- [ ] Manual browser check:
|
||||
- select a few creators on page 1 and page 3
|
||||
- export `当前页` from page 1 and confirm only selected page 1 creators export
|
||||
- export `前5页` and confirm selected creators across pages are included
|
||||
- clear current page via header checkbox and confirm other-page selections remain
|
||||
@ -1,332 +0,0 @@
|
||||
# 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.
|
||||
@ -1,128 +0,0 @@
|
||||
# COS Extension Update Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Point the popup update flow at the real COS release bucket and keep the generated release manifest, docs, and tests aligned with that COS-based distribution path.
|
||||
|
||||
**Architecture:** Reuse the existing update-check flow already in `src/shared/update-check.ts`, `src/popup/index.ts`, `src/popup/view.ts`, and `src/background/index.ts`. The only behavior change is the source of truth: the popup should fetch a stable COS-hosted `latest.json`, while `scripts/write-latest-manifest.mjs` should keep generating versioned asset URLs under the COS release folder. Everything else stays manual and user-driven.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3, Vitest, Node.js ESM scripts, Tencent COS public HTTPS URLs
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the popup manifest URL to COS
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/update-config.ts`
|
||||
- Test: `tests/update-config.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing URL test**
|
||||
|
||||
Add a small test that asserts `UPDATE_MANIFEST_URL` points at the stable COS-hosted `latest.json` URL for this bucket:
|
||||
|
||||
```ts
|
||||
expect(UPDATE_MANIFEST_URL).toBe(
|
||||
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-config.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the current constant still uses the placeholder example URL.
|
||||
|
||||
- [ ] **Step 3: Update the constant**
|
||||
|
||||
Replace the placeholder string in `src/shared/update-config.ts` with the COS `latest.json` URL above.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-config.test.ts tests/popup-entry.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Generate release assets from the COS base
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/write-latest-manifest.mjs`
|
||||
- Modify: `release/latest.json`
|
||||
- Test: `tests/update-check.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write a manifest generation regression test**
|
||||
|
||||
Add or extend a test that proves the generated manifest uses the COS release base for assets, not `example.com`. Use the COS base:
|
||||
|
||||
```ts
|
||||
https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2
|
||||
```
|
||||
|
||||
Assert that `zipUrl` and `guideUrl` are derived from that base.
|
||||
|
||||
- [ ] **Step 2: Run the focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-check.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL until the generator default points at COS.
|
||||
|
||||
- [ ] **Step 3: Update the generator default**
|
||||
|
||||
Change `publicBaseUrl` in `scripts/write-latest-manifest.mjs` to default to the COS release base for this bucket and region, while keeping `UPDATE_PUBLIC_BASE_URL` as the override path for future releases.
|
||||
|
||||
- [ ] **Step 4: Regenerate the tracked manifest**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run write:latest
|
||||
```
|
||||
|
||||
Then confirm `release/latest.json` contains the COS URLs.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test -- tests/update-check.test.ts tests/popup-entry.test.ts tests/background-index.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Update distribution docs and verify COS access
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/internal-extension-distribution.md`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Update the release instructions**
|
||||
|
||||
Document the stable manifest URL, the versioned asset base, and the upload flow to COS. Keep the user-facing manual install steps unchanged.
|
||||
|
||||
- [ ] **Step 2: Add the COS verification command**
|
||||
|
||||
Document a `curl -I` check for the public `latest.json` URL and the uploaded ZIP/PDF so a failed COS ACL is caught before release.
|
||||
|
||||
- [ ] **Step 3: Run the final verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build:release
|
||||
```
|
||||
|
||||
Expected: PASS, and the generated release bundle should still open the popup update card correctly.
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
# 星图达人视频传播数据导出 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:** 导出 CSV 前按配置调用 `get_author_spread_info`,追加个人视频和星图视频传播指标列。
|
||||
|
||||
**Architecture:** 新增独立的 spread-info 模块负责参数配置、URL、响应映射和并发加载;列表解析保留 `authorId`,额外保存 `spreadAuthorId` 作为 `o_author_id`;CSV exporter 只负责把已加载的 spread metrics 输出成列。导出入口在生成 CSV 前补齐 spread metrics。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Spread Info Client And Mapping
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/spread-info.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Test: `tests/spread-info.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Cover URL construction, label/header generation, response mapping, personal-video fixed params, and Xingtu-video multi-param configs.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: FAIL because `spread-info.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement spread-info module and types**
|
||||
|
||||
Implement typed configs, formatter helpers, response mapper, client, and limited-concurrency loader.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Preserve Spread Author ID From Search Rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/market-list-row.ts`
|
||||
- Modify: `src/content/market/page-bridge.ts`
|
||||
- Test: `tests/market-page-bridge.test.ts`
|
||||
- Test: `tests/silent-export-controller.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify `attribute_datas.id` is retained as `spreadAuthorId` and preferred over top-level `star_id` for spread-info requests.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run focused tests for row parsing and silent export.
|
||||
|
||||
- [ ] **Step 3: Implement parser changes**
|
||||
|
||||
Store `spreadAuthorId` on snapshots and merge it in result store.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: CSV Columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/csv-exporter.ts`
|
||||
- Test: `tests/csv-exporter.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify spread headers append after backend metrics and blank cells are exported when metrics are absent.
|
||||
|
||||
- [ ] **Step 2: Implement CSV spread columns**
|
||||
|
||||
Read `record.spreadMetrics` by generated header names.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run: `npx vitest run tests/csv-exporter.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Export Hydration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify export calls spread-info with `spreadAuthorId`, waits before CSV generation, preserves row order, and leaves blanks on failure.
|
||||
|
||||
- [ ] **Step 2: Implement hydration**
|
||||
|
||||
Inject `loadSpreadMetrics` for tests, default to spread-info loader, and hydrate records before `buildCsv`.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run focused content-entry tests.
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
- [ ] Run `npm test`.
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Review `git diff`.
|
||||
@ -1,33 +0,0 @@
|
||||
# 星图达人传播指标阈值筛选 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:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
||||
|
||||
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Toolbar Filter State
|
||||
|
||||
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
||||
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
||||
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
||||
|
||||
### Task 2: Spread Filter Logic
|
||||
|
||||
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
||||
- [ ] 测试百分比显示值、秒、普通数字比较。
|
||||
|
||||
### Task 3: Export And Batch Integration
|
||||
|
||||
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
||||
- [ ] 空阈值不触发筛选请求。
|
||||
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
- [ ] 运行 focused tests。
|
||||
- [ ] 运行 `npm run build`。
|
||||
@ -1,216 +0,0 @@
|
||||
# Logto 受保护 API Mock 联调设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前扩展已经具备基础的 Logto 登录能力:
|
||||
|
||||
- popup 可触发登录和登出
|
||||
- background 可通过 `@logto/chrome-extension` 获取 access token
|
||||
- 页面功能在未登录时会被 auth gate 拦住
|
||||
|
||||
但现阶段业务数据仍然来自页面站内接口,尚未真正验证“扩展拿到 Logto access token 后,请求受保护 API”这条链路。后续真实后端将由其他人提供,因此当前阶段的目标不是接入正式接口,而是先在本地完成一套可重复验证的模拟联调方案。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增一个专用于 Logto 受保护 API 的扩展客户端。
|
||||
- 扩展请求受保护 API 前,必须先通过 background 获取 access token。
|
||||
- 请求时自动附加 `Authorization: Bearer <token>` 请求头。
|
||||
- 提供一个本地 mock 受保护 API,用于验证扩展到后端的完整调用链路。
|
||||
- 提供自动化测试,分别覆盖:
|
||||
- token 注入逻辑
|
||||
- mock API 授权成功/失败行为
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不接入真实业务后端。
|
||||
- 不做真实 JWT 签名校验、JWKS 拉取或资源权限判定。
|
||||
- 不修改现有星图页面数据采集逻辑为正式后端模式。
|
||||
- 不在本阶段设计复杂的 token 刷新监控或重试策略。
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 A:只做本地 mock 后端
|
||||
|
||||
- 直接搭一个假接口,扩展请求它并观察返回。
|
||||
- 优点:最接近最终使用方式。
|
||||
- 缺点:如果联调失败,不容易快速判断问题出在 token 注入还是 mock 服务本身。
|
||||
|
||||
### 方案 B:只做代码级测试
|
||||
|
||||
- 不起本地服务,只用测试替身验证请求头是否带 token。
|
||||
- 优点:实现快,定位问题直接。
|
||||
- 缺点:无法证明完整链路可运行。
|
||||
|
||||
### 方案 C:先代码级测试,再接本地 mock 后端
|
||||
|
||||
- 先用单元测试锁定 token 注入行为,再起 mock 服务完成真实联调。
|
||||
- 优点:定位问题更快,同时保留完整链路验证。
|
||||
- 缺点:改动稍多于单一方案。
|
||||
|
||||
推荐采用方案 C。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 1. 扩展受保护 API 客户端
|
||||
|
||||
新增一个独立客户端模块,职责如下:
|
||||
|
||||
- 向 background 发送 `auth:get-access-token` 消息
|
||||
- 读取返回的 access token
|
||||
- 使用该 token 请求指定后端地址
|
||||
- 将接口成功、未授权、网络失败这三类结果转成明确错误
|
||||
|
||||
这个客户端不直接耦合星图 DOM,也不依赖 popup。它只负责“拿 token 并带 token 发请求”,这样后续替换正式后端时只需要调整接口地址和返回映射。
|
||||
|
||||
### 2. Background 认证桥接
|
||||
|
||||
现有 background 已支持 `auth:get-access-token`。本次不改变登录主流程,只把它当作唯一 token 来源:
|
||||
|
||||
- content script 不直接接触 Logto SDK
|
||||
- 所有受保护 API 请求都通过 background 提供 token
|
||||
|
||||
这样可以保持认证逻辑集中,符合 MV3 扩展的边界约束。
|
||||
|
||||
### 3. 本地 mock 受保护 API
|
||||
|
||||
新增一个轻量本地服务作为测试后端,建议职责保持极小:
|
||||
|
||||
- 暴露固定测试 endpoint,例如 `/api/mock/protected`
|
||||
- 检查请求头中是否存在 `Authorization`
|
||||
- 如果请求头形如 `Bearer <非空字符串>`,返回固定假数据
|
||||
- 如果没有该头,返回 `401`
|
||||
|
||||
本阶段不要求验证 token 是否来自真实 Logto,只验证“扩展是否按约定附带了 Bearer token”。
|
||||
|
||||
## 数据流
|
||||
|
||||
完整调用链路如下:
|
||||
|
||||
1. 扩展中的业务入口调用受保护 API 客户端
|
||||
2. 客户端向 background 发送 `auth:get-access-token`
|
||||
3. background 返回当前 access token
|
||||
4. 客户端带上 `Authorization: Bearer <token>` 请求本地 mock API
|
||||
5. mock API 校验请求头并返回假数据
|
||||
6. 客户端将结果回传给调用方
|
||||
|
||||
失败分支:
|
||||
|
||||
- 如果 background 没返回合法 token,客户端直接报错,不发请求
|
||||
- 如果 mock API 返回 `401`,客户端将其识别为未授权错误
|
||||
- 如果请求超时或网络失败,客户端抛出网络错误
|
||||
|
||||
## 接口设计
|
||||
|
||||
### 扩展内客户端接口
|
||||
|
||||
建议客户端提供单一入口,例如:
|
||||
|
||||
- `loadProtectedMockData()`
|
||||
|
||||
内部行为:
|
||||
|
||||
- 先取 token
|
||||
- 再发 GET 请求
|
||||
- 返回结构化响应对象或抛出明确错误
|
||||
|
||||
后续替换真实后端时,可以保留同样入口,内部再切换到真实 endpoint。
|
||||
|
||||
### Mock API 返回
|
||||
|
||||
成功时返回固定 JSON,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "mock-protected-api",
|
||||
"message": "authorized",
|
||||
"receivedAuthHeader": "Bearer ..."
|
||||
}
|
||||
```
|
||||
|
||||
失败时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 无 token
|
||||
|
||||
- 判定条件:background 未返回 `auth:token`,或 token 为空字符串
|
||||
- 处理方式:立即抛错,提示当前未登录或 token 不可用
|
||||
|
||||
### 未授权
|
||||
|
||||
- 判定条件:后端返回 `401` 或 `403`
|
||||
- 处理方式:抛出授权失败错误,供上层展示“请重新登录”
|
||||
|
||||
### 网络失败
|
||||
|
||||
- 判定条件:fetch 抛异常、连接失败、超时
|
||||
- 处理方式:抛出网络错误,不吞掉异常原因
|
||||
|
||||
## 实现边界
|
||||
|
||||
建议新增或调整如下模块:
|
||||
|
||||
### `src/content/market` 下新增受保护 API 客户端
|
||||
|
||||
- 不替换现有页面接口客户端
|
||||
- 独立处理 mock 受保护 API 访问
|
||||
- 便于后续把“页面抓取模式”和“后端接口模式”并行保留
|
||||
|
||||
### `src/shared/auth-messages.ts`
|
||||
|
||||
- 复用现有 `auth:get-access-token`
|
||||
- 若现有消息结构足够,则不新增消息类型
|
||||
|
||||
### `scripts/` 或独立目录中的 mock 服务
|
||||
|
||||
- 提供本地测试服务启动脚本
|
||||
- 默认监听本地固定端口
|
||||
- 返回固定 JSON 结果
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 客户端请求前会先获取 access token
|
||||
- 拿到 token 后,请求头中包含 `Authorization: Bearer <token>`
|
||||
- token 缺失时不会发起 fetch
|
||||
- 接口返回 `401` 时抛出未授权错误
|
||||
- 接口返回成功时正确解析 JSON
|
||||
|
||||
### 联调测试
|
||||
|
||||
- 启动本地 mock 服务后,带 token 请求能成功
|
||||
- 不带 token 请求返回 `401`
|
||||
- 扩展客户端能读到 mock 返回的假数据
|
||||
|
||||
## 手动验证
|
||||
|
||||
1. 构建并加载扩展
|
||||
2. 在 popup 中完成 Logto 登录
|
||||
3. 启动本地 mock API
|
||||
4. 触发扩展中的受保护接口请求
|
||||
5. 确认 mock API 收到 `Authorization: Bearer <token>`
|
||||
6. 确认扩展端收到成功响应
|
||||
|
||||
## 迁移到真实后端的路径
|
||||
|
||||
当真实后端可用时,仅需要替换以下内容:
|
||||
|
||||
- mock API 基地址
|
||||
- 具体 endpoint 路径
|
||||
- 返回数据结构映射
|
||||
- 若真实后端要求额外 scope,则补充 `auth-config` 中的 scopes
|
||||
|
||||
核心认证链路保持不变:
|
||||
|
||||
- 仍由 background 提供 token
|
||||
- 仍由独立客户端附带 `Bearer` 请求头
|
||||
- 仍按未授权和网络错误分别处理
|
||||
@ -1,63 +0,0 @@
|
||||
# Market Backend Metrics CSV Design
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the existing CSV export so it includes the six backend metrics already shown in the plugin UI.
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- Reuse the current export flow.
|
||||
- Do not add a separate backend request for CSV export.
|
||||
- Read backend metrics directly from the in-memory `MarketRecord`.
|
||||
- Append the six backend metrics columns after the existing CSV columns.
|
||||
- Keep the existing CSV columns and ordering unchanged.
|
||||
- Use these exact CSV headers:
|
||||
- `看后搜率`
|
||||
- `看后搜数`
|
||||
- `新增A3数`
|
||||
- `新增A3率`
|
||||
- `CPA3`
|
||||
- `cp_search`
|
||||
- If a record has no backend metrics, export empty strings for these six columns.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/content/market/csv-exporter.ts` remains the single place that defines CSV column layout.
|
||||
- The exporter will keep current base columns and Xingtu rate columns, then append six backend metrics columns.
|
||||
- No UI changes.
|
||||
- No batch submission changes.
|
||||
- No popup or config changes.
|
||||
|
||||
## Data Source
|
||||
|
||||
Each exported row will read from:
|
||||
|
||||
- existing fields:
|
||||
- `authorId`
|
||||
- `authorName`
|
||||
- `location`
|
||||
- `price21To60s`
|
||||
- `rates.singleVideoAfterSearchRate`
|
||||
- `rates.personalVideoAfterSearchRate`
|
||||
- new backend metrics fields:
|
||||
- `backendMetrics.afterViewSearchRate`
|
||||
- `backendMetrics.afterViewSearchCount`
|
||||
- `backendMetrics.a3IncreaseCount`
|
||||
- `backendMetrics.newA3Rate`
|
||||
- `backendMetrics.cpa3`
|
||||
- `backendMetrics.cpSearch`
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Missing backend metrics: export blank cells
|
||||
- Existing rate formatting behavior remains unchanged
|
||||
- Backend loading state does not alter CSV structure; it only affects whether the cells contain values or blanks
|
||||
|
||||
## Testing
|
||||
|
||||
Add tests for:
|
||||
|
||||
- backend metric headers appended to CSV
|
||||
- backend metric values exported correctly
|
||||
- empty backend metric cells when metrics are absent
|
||||
- no regression in current base/rate export behavior
|
||||
@ -1,110 +0,0 @@
|
||||
# Market Backend Metrics Design
|
||||
|
||||
## Goal
|
||||
|
||||
Connect the current Chrome extension to the real backend search API and display six backend metrics in the plugin UI, while preserving the existing two Xingtu rate columns.
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- Keep the existing two Xingtu metrics unchanged:
|
||||
- `singleVideoAfterSearchRate`
|
||||
- `personalVideoAfterSearchRate`
|
||||
- Add one new UI column named `秒探指标`.
|
||||
- Render the six backend metrics inside that column as a compact 2-column metrics panel:
|
||||
- `avg_after_view_search_rate`
|
||||
- `avg_after_view_search_cnt`
|
||||
- `avg_a3_increase_cnt`
|
||||
- `avg_new_a3_rate`
|
||||
- `cpa3`
|
||||
- `cp_search`
|
||||
- Fetch backend metrics through the background service worker, not directly from the content script.
|
||||
- Use Logto access tokens from the existing background auth flow.
|
||||
- Query the backend by `star_id`, not by nickname.
|
||||
- Send one batched request per visible page of creators.
|
||||
- Use a code-level default backend base URL. Do not build a settings UI in this change.
|
||||
- Do not change CSV export or batch submission payloads in this change.
|
||||
|
||||
## API Contract
|
||||
|
||||
- Base URL:
|
||||
- default to `http://192.168.31.29:8083`
|
||||
- Endpoint:
|
||||
- `POST /api/v1/history/talents/search`
|
||||
- Headers:
|
||||
- `Authorization: Bearer <Logto access token>`
|
||||
- `Content-Type: application/json`
|
||||
- Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "star_id",
|
||||
"values": ["7252982749131178039", "7290491710910496809"],
|
||||
"page": 1,
|
||||
"size": 100
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Background
|
||||
|
||||
- Add a new background message for backend metrics search.
|
||||
- The background handler:
|
||||
- gets the current Logto access token
|
||||
- calls the backend search API
|
||||
- returns parsed metric rows to the content script
|
||||
|
||||
This follows the same rule already used for protected API and batch submission: token-bearing business requests belong in the background layer.
|
||||
|
||||
### Content
|
||||
|
||||
- Replace per-row metric loading with page-level batched loading for backend metrics.
|
||||
- Collect all visible `authorId` values from the current page.
|
||||
- Send them to the background as `star_id[]`.
|
||||
- Map the backend response back to rows by `star_id`.
|
||||
|
||||
### UI
|
||||
|
||||
- Keep the old two columns where they are today.
|
||||
- Add one new `秒探指标` column.
|
||||
- Each cell shows:
|
||||
- `加载中...`
|
||||
- a compact 6-metric panel
|
||||
- `暂无数据`
|
||||
- `加载失败`
|
||||
|
||||
## Data Model
|
||||
|
||||
Add a new backend metrics structure to market records:
|
||||
|
||||
- `afterViewSearchRate`
|
||||
- `afterViewSearchCount`
|
||||
- `a3IncreaseCount`
|
||||
- `newA3Rate`
|
||||
- `cpa3`
|
||||
- `cpSearch`
|
||||
|
||||
The old Xingtu rates stay in the existing `rates` structure.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- No token: show `加载失败`
|
||||
- Backend request failure: show `加载失败`
|
||||
- Backend success but row missing in response: show `暂无数据`
|
||||
- Partial page matches: render matched rows, mark unmatched rows as `暂无数据`
|
||||
|
||||
## Testing
|
||||
|
||||
- Add failing tests first for:
|
||||
- backend metrics request message handling
|
||||
- backend metrics client request/response mapping
|
||||
- page-level batch loading by `star_id`
|
||||
- UI rendering of the new `秒探指标` column for loading, success, missing, and failure states
|
||||
- Run focused tests first, then full `npm test`, then `npm run build`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Popup-based backend URL settings
|
||||
- CSV export changes for the six backend metrics
|
||||
- Batch submit payload changes
|
||||
- Cross-page deduplicated backend caching beyond current in-memory record storage
|
||||
@ -1,305 +0,0 @@
|
||||
# 星图达人批次提交设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前插件已经具备以下能力:
|
||||
|
||||
- 使用 Logto 登录并获取访问 `https://talent-search.intelligrow.cn` 的 access token
|
||||
- 读取星图市场页中的达人列表数据
|
||||
- 支持按当前页、前 5 页、前 10 页、全部、自定义页数进行多页采集
|
||||
- 支持导出 CSV
|
||||
|
||||
下一阶段需要把“当前采集范围内的一批达人”提交给其他同学维护的后端接口,而不是逐条发送。用户期望在现有导出范围逻辑基础上,新增一个独立的“提交批次”动作,用来测试整批提交流程。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在现有工具栏中新增一个独立的 `提交批次` 按钮
|
||||
- 提交动作复用当前导出范围逻辑,支持多页达人数据采集
|
||||
- 提交前通过浏览器 `prompt()` 让用户输入批次名称
|
||||
- 自动生成 `batchId = 批次名称 + 时间戳`
|
||||
- 将以下信息整合为单个批次 payload 发送给后端:
|
||||
- Logto 用户 ID
|
||||
- 星图达人 ID 列表
|
||||
- 用户输入的批次名称
|
||||
- 自动生成的批次 ID
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改造为逐条提交达人
|
||||
- 不移除或替代现有 `导出 CSV`
|
||||
- 不在首版实现复杂弹窗或表单 UI
|
||||
- 不在首版实现批次列表管理、重试历史、草稿保存
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 A:只提交当前页
|
||||
|
||||
- 新按钮只处理当前页达人
|
||||
- 优点:实现最简单
|
||||
- 缺点:与现有多页导出/采集使用方式不一致,不符合真实业务需求
|
||||
|
||||
### 方案 B:提交当前导出范围内的一整批达人
|
||||
|
||||
- 复用现有导出范围控制器,提交当前选择范围内的所有达人
|
||||
- 优点:与用户当前使用习惯一致,便于从测试过渡到真实业务
|
||||
- 缺点:比只提交当前页多一层批次组装逻辑
|
||||
|
||||
### 方案 C:先导出 CSV,再从 CSV 结果提交
|
||||
|
||||
- 先走导出逻辑,再把导出结果转换为提交 payload
|
||||
- 优点:数据来源统一
|
||||
- 缺点:流程绕、耦合高,不适合作为长期模式
|
||||
|
||||
推荐采用方案 B。
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 工具栏按钮
|
||||
|
||||
在现有工具栏中保留 `导出 CSV`,并新增:
|
||||
|
||||
- `提交批次`
|
||||
|
||||
两个按钮共享现有导出范围控件:
|
||||
|
||||
- 当前页
|
||||
- 前 5 页
|
||||
- 前 10 页
|
||||
- 全部
|
||||
- 自定义
|
||||
|
||||
### 批次名称输入
|
||||
|
||||
用户点击 `提交批次` 时:
|
||||
|
||||
1. 弹出浏览器 `prompt()`
|
||||
2. 提示文案建议为:`请输入批次名称`
|
||||
3. 用户输入如:`618 达人筛选第一批`
|
||||
|
||||
处理规则:
|
||||
|
||||
- 用户取消:直接终止,不报错
|
||||
- 输入为空或仅空格:提示 `请输入批次名称`
|
||||
- 输入有效:继续批次提交流程
|
||||
|
||||
### 提交状态
|
||||
|
||||
批次提交过程中:
|
||||
|
||||
- `提交批次` 按钮禁用
|
||||
- `导出 CSV` 按钮与范围选择器一并禁用
|
||||
- 状态文案可复用现有导出状态区域
|
||||
|
||||
建议状态文案:
|
||||
|
||||
- 当前页:`提交中...`
|
||||
- 前 N 页:`提交中 3/5 页...`
|
||||
- 全部:`提交中 第 3 页...`
|
||||
|
||||
### 成功与失败反馈
|
||||
|
||||
成功:
|
||||
|
||||
- 提示 `批次提交成功`
|
||||
|
||||
失败:
|
||||
|
||||
- 未登录:`请先登录插件`
|
||||
- 批次名为空:`请输入批次名称`
|
||||
- 接口失败:`批次提交失败,请稍后重试`
|
||||
|
||||
## 批次数据模型
|
||||
|
||||
首版建议 payload 结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"logtoUserId": "p7pdhhtde8kj",
|
||||
"creatorName": "王少卿",
|
||||
"resource": "https://talent-search.intelligrow.cn",
|
||||
"batchName": "618达人筛选第一批",
|
||||
"batchId": "618达人筛选第一批-2026-04-22T12:30:00.000Z",
|
||||
"createdAt": "2026-04-22T12:30:00.000Z",
|
||||
"authors": [
|
||||
{
|
||||
"authorId": "111",
|
||||
"authorName": "达人A"
|
||||
},
|
||||
{
|
||||
"authorId": "222",
|
||||
"authorName": "达人B"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段来源
|
||||
|
||||
- `logtoUserId`
|
||||
- 来源:当前登录用户的 Logto `sub`
|
||||
- `creatorName`
|
||||
- 来源:当前登录用户 `name` 或 `username`
|
||||
- `resource`
|
||||
- 来源:当前认证配置中的 API resource
|
||||
- `batchName`
|
||||
- 来源:用户在 `prompt()` 中输入
|
||||
- `createdAt`
|
||||
- 来源:当前时间的 ISO 字符串
|
||||
- `batchId`
|
||||
- 规则:`${batchName}-${createdAt}`
|
||||
- `authors`
|
||||
- 来源:当前导出范围内采集出的达人记录
|
||||
|
||||
### 达人字段
|
||||
|
||||
首版只要求最小字段:
|
||||
|
||||
- `authorId`
|
||||
- `authorName`
|
||||
|
||||
后续如有需要,可增加:
|
||||
|
||||
- `price21To60s`
|
||||
- 看后搜率字段
|
||||
- 城市/地区等补充信息
|
||||
|
||||
## 数据流
|
||||
|
||||
完整流程如下:
|
||||
|
||||
1. 用户在工具栏选择导出范围
|
||||
2. 用户点击 `提交批次`
|
||||
3. 插件弹出 `prompt()` 请求输入批次名称
|
||||
4. 插件检查登录状态与用户信息
|
||||
5. 插件复用当前多页采集逻辑,获取当前范围内达人记录
|
||||
6. 插件读取 Logto 用户 ID
|
||||
7. 插件生成 `createdAt` 和 `batchId`
|
||||
8. 插件组装整批 payload
|
||||
9. 插件使用带 Bearer token 的客户端提交到后端
|
||||
10. 插件显示成功或失败状态
|
||||
|
||||
## 模块边界
|
||||
|
||||
建议按以下边界实现:
|
||||
|
||||
### 工具栏层
|
||||
|
||||
职责:
|
||||
|
||||
- 渲染 `提交批次` 按钮
|
||||
- 管理按钮禁用态
|
||||
- 回传点击事件
|
||||
|
||||
### 市场控制器层
|
||||
|
||||
职责:
|
||||
|
||||
- 响应 `提交批次` 点击
|
||||
- 调用 `prompt()` 获取批次名称
|
||||
- 复用现有导出范围采集逻辑
|
||||
- 调用批次 payload 组装器和提交客户端
|
||||
|
||||
### 批次 payload 组装层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收用户信息、批次名、时间戳、达人记录
|
||||
- 生成标准批次对象
|
||||
|
||||
该层不负责网络请求,方便后续根据后端要求调整结构。
|
||||
|
||||
### 批次提交客户端
|
||||
|
||||
职责:
|
||||
|
||||
- 获取 access token
|
||||
- 附带 `Authorization: Bearer <token>` 请求头
|
||||
- 向后端提交整批 JSON
|
||||
|
||||
后续从 mock 接口切到真实接口时,应主要修改这一层。
|
||||
|
||||
## 认证与授权
|
||||
|
||||
当前系统已具备:
|
||||
|
||||
- Logto 登录能力
|
||||
- 请求 `https://talent-search.intelligrow.cn` resource 的 access token
|
||||
- `talent-search:read` scope
|
||||
|
||||
首版批次提交如果是写操作,后端后续可能需要新增写权限,例如:
|
||||
|
||||
- `talent-search:write`
|
||||
- 或 `talent-search:batch:submit`
|
||||
|
||||
如果后端要求新的写权限,则前端需要同步更新 `auth-config.ts` 中的 `scopes`。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 用户取消输入
|
||||
|
||||
- 直接停止,不提示错误
|
||||
|
||||
### 批次名为空
|
||||
|
||||
- 不发请求
|
||||
- 直接提示 `请输入批次名称`
|
||||
|
||||
### 未登录或取不到用户信息
|
||||
|
||||
- 不发请求
|
||||
- 提示 `请先登录插件`
|
||||
|
||||
### 采集失败
|
||||
|
||||
- 复用当前导出多页采集失败逻辑
|
||||
- 不生成不完整 payload
|
||||
|
||||
### 接口失败
|
||||
|
||||
- 提示 `批次提交失败,请稍后重试`
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 点击 `提交批次` 时会弹出 `prompt()`
|
||||
- 用户取消输入时不会继续提交
|
||||
- 空白批次名会报错
|
||||
- `batchId` 按 `批次名 + ISO 时间戳` 生成
|
||||
- payload 中包含:
|
||||
- `logtoUserId`
|
||||
- `batchName`
|
||||
- `batchId`
|
||||
- `authors[]`
|
||||
|
||||
### 集成测试
|
||||
|
||||
- `提交批次` 复用当前导出范围采集逻辑
|
||||
- 当前页模式只提交当前页达人
|
||||
- 多页模式会合并多页达人
|
||||
- 接口调用时带 Bearer token
|
||||
- 成功时显示成功状态
|
||||
- 失败时显示失败状态
|
||||
|
||||
## 手动验证
|
||||
|
||||
1. 登录插件
|
||||
2. 选择导出范围
|
||||
3. 点击 `提交批次`
|
||||
4. 在 `prompt()` 中输入批次名
|
||||
5. 确认请求 payload 中包含:
|
||||
- Logto 用户 ID
|
||||
- 多个达人 ID
|
||||
- 批次名
|
||||
- 批次 ID
|
||||
6. 确认后端返回成功
|
||||
|
||||
## 后续扩展
|
||||
|
||||
当真实接口稳定后,可以继续扩展:
|
||||
|
||||
- 批次提交结果详情展示
|
||||
- 最近提交批次列表
|
||||
- 批次重试
|
||||
- 批次备注、标签
|
||||
- 写权限 scope 单独拆分
|
||||
@ -1,35 +0,0 @@
|
||||
# Market Scrollable Plugin Columns Design
|
||||
|
||||
**Goal:** Keep the plugin-generated columns visible without letting them cover Xingtu's native middle columns.
|
||||
|
||||
**Problem:** The current implementation injects plugin columns into the right sticky section. That expands the sticky width and causes native middle columns such as `预期播放量` and `互动率` to be visually covered.
|
||||
|
||||
**Approved Direction:** Keep plugin columns always visible, but move them out of the right sticky area. The plugin columns should live in the horizontally scrollable middle area so they scroll together with the native table columns.
|
||||
|
||||
## Design
|
||||
|
||||
### Layout
|
||||
|
||||
- Preserve the native left sticky author section.
|
||||
- Preserve the native right sticky section for Xingtu's own right-side columns, especially `21-60s报价` and `操作`.
|
||||
- Insert plugin columns as a separate non-sticky section immediately before the right sticky section.
|
||||
- Let the plugin section participate in the same horizontal flow as the middle columns so users reach it through horizontal scrolling.
|
||||
|
||||
### DOM Sync Responsibilities
|
||||
|
||||
- `syncDivGridRoot()` remains responsible for locating the author section, middle columns, and right sticky section.
|
||||
- Plugin header cells and plugin body columns should no longer be inserted into `actionHeader.parentElement` / `actionColumn.parentElement`.
|
||||
- A dedicated plugin section should be created or reused under the header row and body row.
|
||||
- Row alignment logic should still read plugin cells row-by-row for rendering, visibility, and ordering.
|
||||
|
||||
### Behavior
|
||||
|
||||
- Export, filtering, sorting, and row hydration should keep working unchanged.
|
||||
- Only column placement changes.
|
||||
- Existing synthetic table mode is unaffected.
|
||||
|
||||
### Testing
|
||||
|
||||
- Add regression coverage proving the right sticky section width stays at the native width.
|
||||
- Add regression coverage proving plugin columns render in a separate non-sticky section.
|
||||
- Keep existing export and hydration behavior green.
|
||||
@ -1,94 +0,0 @@
|
||||
# Market Native Action Bar Design
|
||||
|
||||
## Goal
|
||||
|
||||
把插件当前位于页面顶部的工具栏移除,并将真正保留的市场页动作迁移到星图原生操作区中,与 `自定义指标` 和原生 `导出` 处于同一行。
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- 删除页面顶部整块插件工具栏。
|
||||
- 只保留这几个插件能力:
|
||||
- 导出范围选择
|
||||
- 自定义页数输入
|
||||
- `导出 CSV`
|
||||
- `提交批次`
|
||||
- 状态文案
|
||||
- 排序和筛选控件从插件工具栏中删除。
|
||||
- 新的插件操作区放在 `自定义指标` 和原生 `导出` 左侧。
|
||||
- 主按钮风格尽量复用星图当前页的原生 `xt-button` / `el-button` 样式。
|
||||
- 不新增弹窗式复杂交互;导出范围继续直接显示在操作区内。
|
||||
|
||||
## Layout
|
||||
|
||||
### Placement
|
||||
|
||||
- 在市场页标题区按钮行内查找 `自定义指标` 和原生 `导出` 所在的横向容器。
|
||||
- 在该容器中插入一个插件 action bar。
|
||||
- 插件 action bar 放在这两个原生按钮的左边。
|
||||
|
||||
### Visual Structure
|
||||
|
||||
插件 action bar 包含:
|
||||
|
||||
- `导出范围` 下拉框
|
||||
- `自定义页数` 输入框,仅在 `自定义` 范围下显示
|
||||
- `导出 CSV` 按钮
|
||||
- `提交批次` 按钮
|
||||
- 状态文本
|
||||
|
||||
视觉目标:
|
||||
|
||||
- 两个主按钮与原生按钮高度、圆角、边框、字体风格一致
|
||||
- 范围选择器和页数输入框比按钮略窄,但整体高度对齐
|
||||
- 状态文案弱化显示,不抢占主要视觉注意力
|
||||
|
||||
## Behavior
|
||||
|
||||
- `导出 CSV` 和 `提交批次` 的业务逻辑保持不变。
|
||||
- 导出范围逻辑保持不变:
|
||||
- 当前页
|
||||
- 前 5 页
|
||||
- 前 10 页
|
||||
- 全部
|
||||
- 自定义
|
||||
- 选择 `自定义` 时显示页数输入框,否则隐藏。
|
||||
- 正在导出或提交时:
|
||||
- `导出 CSV` 按钮禁用
|
||||
- `提交批次` 按钮禁用
|
||||
- 范围下拉禁用
|
||||
- 自定义页数输入禁用
|
||||
- 顶部原工具栏不再出现。
|
||||
|
||||
## DOM Strategy
|
||||
|
||||
- `ensurePluginToolbar()` 不再固定 prepend 到 `document.body`。
|
||||
- 它改为:
|
||||
- 先查找市场页原生操作区
|
||||
- 在原生按钮组内创建或复用插件 action bar
|
||||
- 若页面局部重渲染导致节点丢失,内容脚本在后续同步中重新确保其存在
|
||||
- 工具栏 DOM 结构只保留本次需要的字段,删除筛选和排序输入控件。
|
||||
|
||||
## Controller Impact
|
||||
|
||||
- `src/content/market/index.ts` 不再从工具栏读取筛选和排序输入值。
|
||||
- 现有点击表头排序的能力本轮不主动扩展,但不以顶部工具栏为依赖。
|
||||
- 忙碌状态、状态文本、导出范围读取逻辑继续保留。
|
||||
|
||||
## Testing
|
||||
|
||||
需要增加或更新测试,覆盖:
|
||||
|
||||
- 顶部旧工具栏不再挂载到 `document.body` 顶部
|
||||
- 新 action bar 挂载到原生按钮组,并位于 `自定义指标` / `导出` 左侧
|
||||
- 工具栏只保留导出范围、自定义页数、导出、提交、状态文案
|
||||
- 自定义范围显示/隐藏页数输入框
|
||||
- 导出和提交忙碌状态仍能正确禁用相关控件
|
||||
- 自定义范围校验和批次提交路径不回归
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不新增新的筛选 UI
|
||||
- 不新增新的排序 UI
|
||||
- 不重做表头排序交互
|
||||
- 不改动 CSV 内容结构
|
||||
- 不改动批次 payload 结构
|
||||
@ -1,183 +0,0 @@
|
||||
# Market Selection Export Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add row-level selection checkboxes to the market table so users can selectively export CSV data and submit batches for chosen creators, while preserving the existing export range workflow.
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- Add one checkbox column before each creator row.
|
||||
- Add a header checkbox that selects or clears only the creators visible on the current page.
|
||||
- Selection state persists across pagination.
|
||||
- Selection affects both:
|
||||
- CSV export
|
||||
- batch submission
|
||||
- If the current export range contains any selected creators, export and submit only those selected creators within that range.
|
||||
- If the current export range contains no selected creators, fall back to all creators in the current export range.
|
||||
- Keep the existing export range selector and current toolbar layout.
|
||||
- Do not change the CSV column schema.
|
||||
- Do not change the batch payload shape. Only change which records are included.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Table Controls
|
||||
|
||||
- Each creator row gets a checkbox at the far left.
|
||||
- The table header gets a tri-state checkbox:
|
||||
- unchecked: none of the current page creators are selected
|
||||
- indeterminate: some but not all current page creators are selected
|
||||
- checked: all current page creators are selected
|
||||
- Clicking the header checkbox toggles selection for the current visible page only.
|
||||
|
||||
### Export and Submit Behavior
|
||||
|
||||
- Export still starts from the current range selector:
|
||||
- current page
|
||||
- first 5 pages
|
||||
- custom pages
|
||||
- all pages
|
||||
- After range resolution:
|
||||
- if any creators in that resolved range are selected, use only those selected creators
|
||||
- if none are selected in that resolved range, use the full resolved range
|
||||
- Batch submit uses the same filtered creator set as CSV export.
|
||||
|
||||
### Status Feedback
|
||||
|
||||
- Keep the existing export status area.
|
||||
- Add lightweight selection feedback in the toolbar status text when helpful, for example:
|
||||
- `已勾选 7 位达人`
|
||||
- Do not add extra selection mode toggles or secondary panels.
|
||||
|
||||
## Data Model
|
||||
|
||||
Selection is UI state, not record data.
|
||||
|
||||
- Maintain selection in the controller as a `Set<string>` keyed by `authorId`.
|
||||
- Do not store selection inside the CSV exporter.
|
||||
- Do not mutate `MarketRecord` with persistent selection fields unless required for DOM wiring.
|
||||
- Resolve selection against `authorId` only so sorting, filtering, and row reordering do not break checkbox state.
|
||||
|
||||
## DOM Design
|
||||
|
||||
### Synthetic Table
|
||||
|
||||
- Insert a selectable header cell before the author column.
|
||||
- Insert one checkbox cell per row.
|
||||
- Expose the row checkbox and header checkbox through `MarketRowDom` and table DOM helpers.
|
||||
|
||||
### Div Grid Table
|
||||
|
||||
- Clone a narrow column before the native author column.
|
||||
- Render checkbox cells aligned to each creator row.
|
||||
- Render the header checkbox in the sticky header section.
|
||||
- Reuse the existing plugin section insertion pattern so checkbox layout survives page refresh and plugin re-sync.
|
||||
|
||||
## Controller Design
|
||||
|
||||
### New Responsibilities
|
||||
|
||||
- Track selected creator ids across page changes.
|
||||
- Re-apply checkbox state after every DOM re-sync.
|
||||
- Update header checkbox state after:
|
||||
- row checkbox changes
|
||||
- header checkbox changes
|
||||
- pagination changes
|
||||
- sorting and filtering changes
|
||||
|
||||
### Export Flow
|
||||
|
||||
Current flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- build CSV
|
||||
|
||||
New flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- filter records by selection-with-fallback rule
|
||||
- build CSV
|
||||
|
||||
### Batch Flow
|
||||
|
||||
Current flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- build batch payload
|
||||
- submit
|
||||
|
||||
New flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- filter records by selection-with-fallback rule
|
||||
- build batch payload from filtered records
|
||||
- submit
|
||||
|
||||
## Filtering Rule
|
||||
|
||||
Given the resolved export records:
|
||||
|
||||
1. Find the subset whose `authorId` exists in the selection set.
|
||||
2. If that subset is non-empty, use it.
|
||||
3. If that subset is empty, use the original resolved records.
|
||||
|
||||
This rule intentionally scopes selection to the chosen export range. For example:
|
||||
|
||||
- If the user selected creators on page 1 and page 3
|
||||
- then exports `当前页` while viewing page 1
|
||||
- only page 1 selected creators are used
|
||||
- page 3 selections remain stored for later exports
|
||||
|
||||
## File Impact
|
||||
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- checkbox column insertion
|
||||
- row and header checkbox lookup
|
||||
- checkbox state sync helpers
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- selection state storage
|
||||
- event handling
|
||||
- export and batch record filtering
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- minimal DOM type additions if needed
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
- checkbox column rendering
|
||||
- header checkbox presence
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
- row selection
|
||||
- current page header select all
|
||||
- cross-page selection persistence
|
||||
- export fallback when no selected creators are inside the resolved range
|
||||
- export filtering when selected creators exist in the resolved range
|
||||
- batch submit uses the same filtered set
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: Selection breaks after reordering
|
||||
|
||||
Mitigation:
|
||||
- key selection only by `authorId`
|
||||
- never key by row index or DOM order
|
||||
|
||||
### Risk: Header checkbox selects hidden or unloaded rows
|
||||
|
||||
Mitigation:
|
||||
- limit header checkbox operations to current page visible row DOMs only
|
||||
|
||||
### Risk: User expects selection to always override export range
|
||||
|
||||
Mitigation:
|
||||
- keep selection constrained to the resolved export range
|
||||
- preserve current range selector semantics
|
||||
|
||||
### Risk: Selection UI adds visual clutter
|
||||
|
||||
Mitigation:
|
||||
- use a narrow first column
|
||||
- follow the host page checkbox look and spacing as closely as practical
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Bulk actions beyond export and batch submit
|
||||
- Selection persistence across browser reloads
|
||||
- Dedicated “selected only” mode in the toolbar
|
||||
- Server-side storage of selected creators
|
||||
@ -1,105 +0,0 @@
|
||||
# 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.
|
||||
@ -1,84 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
# 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
|
||||
@ -1,122 +0,0 @@
|
||||
# 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
|
||||
@ -1,242 +0,0 @@
|
||||
# 星图达人视频传播数据导出需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在现有星图达人 CSV 导出流程中,额外调用星图接口 `get_author_spread_info`,获取达人视频传播相关指标,并把这些指标追加到导出表格中。
|
||||
|
||||
因为同一个指标在不同参数组合下含义不同,所以导出字段名必须带上参数前缀。例如:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
这个字段表示:它不是普通的“完播率”,而是在“只看指派 + 排除营销流量 + 星图视频 + 近30天”这组参数下获取到的完播率。
|
||||
|
||||
字段名前缀只体现会造成数据差异、且在当前导出中可变化的参数。固定不变的参数不用写进字段名前缀。
|
||||
|
||||
## 接口
|
||||
|
||||
调用接口:
|
||||
|
||||
```text
|
||||
GET /gw/api/data_sp/get_author_spread_info
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 参数 | 含义 |
|
||||
| --- | --- |
|
||||
| `o_author_id` | 达人的星图 ID |
|
||||
| `platform_source` | 固定传 `1` |
|
||||
| `platform_channel` | 固定传 `1` |
|
||||
| `type` | 视频类型 |
|
||||
| `flow_type` | 是否排除营销流量 |
|
||||
| `only_assign` | 是否只看指派 |
|
||||
| `range` | 数据时间范围 |
|
||||
|
||||
请求需要带上当前星图网页登录态,所以实现时请求要使用浏览器当前 cookie,也就是 `credentials: "include"`。
|
||||
|
||||
## 星图 ID 来源
|
||||
|
||||
`o_author_id` 需要从 `search_for_author_square` 接口返回值中获取:
|
||||
|
||||
```text
|
||||
authors[i].attribute_datas.id
|
||||
```
|
||||
|
||||
如果同一行数据里同时存在顶层 `star_id` 和 `attribute_datas.id`,这个接口优先使用 `attribute_datas.id` 作为 `o_author_id`。
|
||||
|
||||
## 参数含义
|
||||
|
||||
### only_assign
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `true` | 只看指派 | `只看指派` |
|
||||
| `false` | 取消“只看指派”勾选 | `不限指派` |
|
||||
|
||||
### flow_type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 排除营销流量 | `排除营销流量` |
|
||||
| `0` | 不排除营销流量 | `不排除营销流量` |
|
||||
|
||||
### range
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `2` | 近 30 天 | `近30天` |
|
||||
| `3` | 近 90 天 | `近90天` |
|
||||
|
||||
### type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 个人视频 | `个人视频` |
|
||||
| `2` | 星图视频 | `星图视频` |
|
||||
|
||||
## 多组参数导出
|
||||
|
||||
第一版需要支持多组参数组合。
|
||||
|
||||
参数组合需要区分“个人视频”和“星图视频”两类处理:
|
||||
|
||||
- `type=1` 个人视频:`only_assign=false`、`flow_type=0` 固定,只允许调整 `range`。
|
||||
- `type=2` 星图视频:需要支持多组参数组合,因为 `only_assign`、`flow_type`、`range` 的不同设置会导致接口返回的数据不同。
|
||||
|
||||
个人视频固定参数:
|
||||
|
||||
```text
|
||||
type=1
|
||||
flow_type=0
|
||||
only_assign=false
|
||||
```
|
||||
|
||||
个人视频可变参数:
|
||||
|
||||
```text
|
||||
range=2 或 range=3
|
||||
```
|
||||
|
||||
因为个人视频里 `only_assign=false` 和 `flow_type=0` 是固定参数,所以它们不写入字段名前缀。个人视频字段只需要体现视频类型和时间范围,例如:
|
||||
|
||||
```text
|
||||
个人视频_近30天_完播率
|
||||
个人视频_近90天_完播率
|
||||
```
|
||||
|
||||
星图视频可以配置多组参数。每一组参数都会调用一次 `get_author_spread_info`,并为这一组参数生成 7 个导出字段。
|
||||
|
||||
例如某一组参数是:
|
||||
|
||||
```text
|
||||
only_assign=true
|
||||
flow_type=1
|
||||
type=2
|
||||
range=2
|
||||
```
|
||||
|
||||
那么这一组会生成:
|
||||
|
||||
- `只看指派_排除营销流量_星图视频_近30天_完播率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_播放量中位数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_互动率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均时长`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均评论数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均点赞数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均转发数`
|
||||
|
||||
字段名规则固定为:
|
||||
|
||||
```text
|
||||
<会变化的参数文案>_<视频类型文案>_<时间范围文案>_<指标名>
|
||||
```
|
||||
|
||||
对星图视频来说,`only_assign`、`flow_type`、`range` 都可能变化,所以字段名要保留这些参数。对个人视频来说,只有 `range` 变化,所以字段名不需要写 `不限指派` 和 `不排除营销流量`。
|
||||
|
||||
这里必须保留会变化参数的前缀,不能把不同参数组合下的同名指标合并。例如下面两个字段都叫“完播率”,但数据含义不同,必须作为两个独立字段导出:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
不限指派_不排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
## 需要导出的指标
|
||||
|
||||
每一组参数都要导出下面 7 个指标:
|
||||
|
||||
| 导出字段指标名 | 接口响应字段 | 示例值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 完播率 | `play_over_rate.value` | `2824` | 按万分比理解,导出时建议显示为 `28.24%` |
|
||||
| 播放量中位数 | `play_mid`,兜底 `item_rate.play_mid.value` | `10913233` | 播放量中位数 |
|
||||
| 互动率 | `interact_rate.value` | `402` | 按万分比理解,导出时建议显示为 `4.02%` |
|
||||
| 作品平均时长 | `avg_duration` | `5600` | 按百分之一秒理解,导出时显示为秒,例如 `56` |
|
||||
| 作品平均评论数 | `comment_avg` | `7502` | 平均评论数 |
|
||||
| 作品平均点赞数 | `like_avg` | `494458` | 平均点赞数 |
|
||||
| 作品平均转发数 | `share_avg` | `188267` | 平均转发数 |
|
||||
|
||||
示例响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_duration": "5600",
|
||||
"comment_avg": "7502",
|
||||
"interact_rate": {
|
||||
"overtake": 5312,
|
||||
"value": 402
|
||||
},
|
||||
"item_rate": {
|
||||
"play_mid": {
|
||||
"label": "",
|
||||
"overtake": 10000,
|
||||
"value": 10913233
|
||||
}
|
||||
},
|
||||
"like_avg": "494458",
|
||||
"play_mid": "10913233",
|
||||
"play_over_rate": {
|
||||
"overtake": 9584,
|
||||
"value": 2824
|
||||
},
|
||||
"share_avg": "188267"
|
||||
}
|
||||
```
|
||||
|
||||
## 导出流程
|
||||
|
||||
1. 当前插件仍然先从星图达人搜索页收集达人列表。
|
||||
2. 从 `search_for_author_square` 的 `authors[i].attribute_datas.id` 取出每个达人的星图 ID。
|
||||
3. 用户导出 CSV 时,先按现有逻辑确定导出范围,例如当前页、前 5 页、前 10 页、全部或自定义页数。
|
||||
4. 对导出范围内的每个达人,先按个人视频参数调用 `get_author_spread_info`:`type=1`、`flow_type=0`、`only_assign=false` 固定,`range` 按配置取值。
|
||||
5. 如果配置了星图视频参数组合,再按每一组星图视频参数分别调用 `get_author_spread_info`。
|
||||
6. 把每次接口返回值解析成 7 个指标。
|
||||
7. CSV 保留原有字段顺序,在现有字段后追加这些带参数前缀的新字段。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 如果某个达人没有 `attribute_datas.id`,这一行的视频传播指标留空。
|
||||
- 如果某个参数组合请求失败,这一组参数对应的 7 个字段留空。
|
||||
- 如果接口响应结构异常,这一组参数对应的 7 个字段留空。
|
||||
- 某个达人失败不能影响其他达人导出。
|
||||
- 某组参数失败不能影响同一个达人的其他参数组导出。
|
||||
|
||||
## 性能要求
|
||||
|
||||
这个功能会产生比较多接口请求:
|
||||
|
||||
```text
|
||||
请求数 = 导出的达人数量 * 参数组合数量
|
||||
```
|
||||
|
||||
所以实现时需要:
|
||||
|
||||
- 做并发限制,避免一次性打太多请求。
|
||||
- 保持最终 CSV 行顺序和原导出顺序一致。
|
||||
- 给每个请求设置超时时间。
|
||||
- 第一版不做激进重试,避免接口压力过大。
|
||||
|
||||
## 测试要求
|
||||
|
||||
需要补充测试覆盖:
|
||||
|
||||
- `get_author_spread_info` URL 参数构造是否正确。
|
||||
- `type=1` 生成 `个人视频` 前缀。
|
||||
- `type=2` 生成 `星图视频` 前缀。
|
||||
- 个人视频是否固定使用:`type=1`、`flow_type=0`、`only_assign=false`。
|
||||
- 个人视频是否支持切换 `range=2` 和 `range=3`。
|
||||
- 个人视频字段名前缀是否不包含固定参数 `不限指派` 和 `不排除营销流量`。
|
||||
- 星图视频是否支持多组参数组合。
|
||||
- `only_assign`、`flow_type`、`range` 前缀是否正确。
|
||||
- 是否从 `attribute_datas.id` 读取 `o_author_id`。
|
||||
- 多组参数是否分别生成 7 个字段。
|
||||
- 响应字段是否正确映射到 7 个导出指标。
|
||||
- 接口失败时是否导出空字段。
|
||||
- 多个达人并发请求完成顺序不一致时,最终 CSV 行顺序是否保持不变。
|
||||
|
||||
## 暂不做的事情
|
||||
|
||||
- 暂不新增页面上的参数配置 UI。
|
||||
- 暂不改变星图搜索页原本的筛选条件。
|
||||
- 暂不改变现有后端指标字段。
|
||||
- 暂不改变批次提交 payload。
|
||||
@ -1,101 +0,0 @@
|
||||
# 星图达人传播指标阈值筛选需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
||||
|
||||
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
||||
|
||||
## 筛选维度
|
||||
|
||||
筛选维度对应 `get_author_spread_info` 的请求参数:
|
||||
|
||||
| UI 维度 | 接口参数 | 可选值 |
|
||||
| --- | --- | --- |
|
||||
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
||||
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
||||
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
||||
| 数据范围 | `range` | 近30天 / 近90天 |
|
||||
|
||||
个人视频的参数约束:
|
||||
|
||||
- `type=1`
|
||||
- `only_assign=false`
|
||||
- `flow_type=0`
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
星图视频的参数约束:
|
||||
|
||||
- `type=2`
|
||||
- `only_assign` 可选
|
||||
- `flow_type` 可选
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
## 指标阈值
|
||||
|
||||
支持下面 7 个指标阈值:
|
||||
|
||||
- 完播率 >=
|
||||
- 播放量中位数 >=
|
||||
- 互动率 >=
|
||||
- 作品平均时长 >=
|
||||
- 作品平均评论数 >=
|
||||
- 作品平均点赞数 >=
|
||||
- 作品平均转发数 >=
|
||||
|
||||
规则:
|
||||
|
||||
- 没填的阈值不参与筛选。
|
||||
- 填了多个阈值时,必须全部满足才保留达人。
|
||||
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
||||
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
||||
- 播放量、评论、点赞、转发使用普通数字。
|
||||
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
||||
|
||||
## 生效范围
|
||||
|
||||
阈值筛选同时作用于:
|
||||
|
||||
- 导出 CSV
|
||||
- 提交批次
|
||||
|
||||
处理顺序:
|
||||
|
||||
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
||||
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
||||
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
||||
4. 将接口响应映射为显示值。
|
||||
5. 用已填写的阈值过滤达人。
|
||||
6. 过滤后的达人进入导出 CSV 或提交批次。
|
||||
|
||||
## UI 设计
|
||||
|
||||
在现有插件操作区中增加一组紧凑控件:
|
||||
|
||||
- 视频类别下拉框
|
||||
- 指派下拉框
|
||||
- 营销流量下拉框
|
||||
- 数据范围下拉框
|
||||
- 7 个数字输入框
|
||||
|
||||
当视频类别选择“个人视频”时:
|
||||
|
||||
- 指派固定为“不限指派”
|
||||
- 营销流量固定为“不排除营销流量”
|
||||
- 对应控件禁用
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 单个达人筛选请求失败:该达人不满足筛选。
|
||||
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
||||
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 读取 toolbar 中的筛选参数和阈值。
|
||||
- 个人视频禁用指派和营销流量控件。
|
||||
- 空阈值时不触发二次筛选。
|
||||
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
||||
- 百分比阈值按显示值比较。
|
||||
- 多个阈值按 AND 关系过滤。
|
||||
- 导出 CSV 和提交批次都应用二次筛选。
|
||||
@ -1,153 +0,0 @@
|
||||
# 📥 从 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` 文件夹
|
||||
|
||||
## ♻️ 老用户一次性升级说明
|
||||
|
||||
如果你之前已经装过比较早的旧版本,而且插件弹窗里一直显示:
|
||||
|
||||
- `暂时无法检查更新`
|
||||
|
||||
那通常说明你本地还是旧的更新机制,建议先手动完成一次升级:
|
||||
|
||||
1. 先获取我发出的最新压缩包
|
||||
2. 解压后得到新的 `dist` 文件夹
|
||||
3. 打开 `chrome://extensions`
|
||||
4. 删除旧插件,或重新执行一次 **"加载已解压的扩展程序"**
|
||||
5. 重新选择新的 `dist` 文件夹
|
||||
|
||||
完成这一次后,后续再更新时,就可以继续沿用统一的 `dist` 目录方式。
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
**Q: 提示 "git 不是内部或外部命令"?**
|
||||
A: Git 没装好,请先安装 Git。
|
||||
|
||||
**Q: 下载后找不到 dist 文件夹?**
|
||||
A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新执行 `npm run build:release`。
|
||||
|
||||
**Q: 加载后扩展 ID 不对?**
|
||||
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
|
||||
|
||||
**Q: 我已经在用这个插件了,还需要再用压缩包更新一次吗?**
|
||||
A: 不一定。只有那些当前弹窗仍然只显示 `暂时无法检查更新` 的旧用户,才建议手动用最新压缩包重新装一次 `dist` 来完成过桥升级。已经能正常发现新版本的同事,继续按普通更新流程走即可。
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果以上步骤遇到问题,请截图发给我:
|
||||
1. 你在哪一步卡住了
|
||||
2. 完整的错误提示
|
||||
3. Chrome 扩展页面的截图
|
||||
@ -1,198 +0,0 @@
|
||||
# 🌟 星图增强插件 - 超简单使用指南
|
||||
|
||||
> 适合:完全没用过插件的新手 | 阅读时间: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` 文件夹
|
||||
|
||||
## ♻️ 老用户一次性升级说明
|
||||
|
||||
如果你之前安装的是较早的旧版本,并且插件弹窗里一直显示:
|
||||
|
||||
- `暂时无法检查更新`
|
||||
|
||||
那通常说明你本地还在使用“旧更新机制”的插件包。
|
||||
|
||||
这种情况下,需要**手动用一次最新压缩包升级**:
|
||||
|
||||
1. 下载最新的 `star-chart-search-enhancer-internal.zip`
|
||||
2. 解压后得到新的 `dist` 文件夹
|
||||
3. 打开 `chrome://extensions`
|
||||
4. 删除旧插件,或重新点击 **"加载已解压的扩展程序"**
|
||||
5. 重新选择新的 `dist` 文件夹
|
||||
|
||||
完成这一次“过桥升级”后,后面再看到新版本时,就可以继续按统一的 `dist` 更新方式操作。
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 页面没有多出按钮?
|
||||
A: 先点击插件图标确认已登录,然后刷新页面(按 F5)
|
||||
|
||||
### Q: 提示登录失败?
|
||||
A: 关闭弹窗再试一次,或检查网络连接
|
||||
|
||||
### Q: 导出没反应?
|
||||
A: 检查浏览器的下载列表,文件可能已经下好了
|
||||
|
||||
### Q: 不小心把文件夹删了?
|
||||
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
||||
|
||||
### Q: 我已经在用插件了,还需要再用一次压缩包更新吗?
|
||||
A: 如果你当前弹窗能正常显示 `发现新版本`,就不需要额外做特殊处理,按普通更新步骤走即可。如果弹窗一直只显示 `暂时无法检查更新`,建议手动用最新压缩包重新安装一次 `dist`,完成一次性升级。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 每日使用 checklist
|
||||
|
||||
- [ ] 打开 Chrome,确认插件图标在
|
||||
- [ ] 点击图标,确认显示"已登录"
|
||||
- [ ] 打开星图页面
|
||||
- [ ] 正常使用导出/提交功能
|
||||
|
||||
---
|
||||
|
||||
## 🆘 还是不行?
|
||||
|
||||
把下面信息发给同事:
|
||||
1. 你在哪一步卡住了
|
||||
2. 页面截图
|
||||
3. 扩展 ID(从 chrome://extensions 里看)
|
||||
|
||||
**记住正确的 ID:`pkjopdibdnomhogjheclhnknmejccffg`**
|
||||
730
docs/项目流程说明文档.md
730
docs/项目流程说明文档.md
@ -1,730 +0,0 @@
|
||||
# 项目流程说明文档
|
||||
|
||||
## 1. 项目用途
|
||||
|
||||
本项目是公司内部使用的 Chrome 插件,用于增强巨量星图达人市场页面的达人筛选、数据导出和批次提交效率。
|
||||
|
||||
它解决的业务问题是:使用者在星图达人市场中筛选达人后,可以直接在页面上补充查看看后搜率、秒思后台指标等数据,并把选中的达人导出为 CSV,或提交为后续业务处理批次。
|
||||
|
||||
项目输入包括:
|
||||
|
||||
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
|
||||
- 使用者在页面上勾选的达人;
|
||||
- 使用者粘贴的达人星图 ID;
|
||||
- 使用者填写的批次名称;
|
||||
- 使用者选择的导出字段和传播指标筛选阈值;
|
||||
- 当前插件登录用户的 Logto 身份和访问令牌。
|
||||
|
||||
项目处理过程包括:
|
||||
|
||||
- 在星图达人市场页面挂载插件工具栏;
|
||||
- 读取当前页面或星图列表接口返回的达人数据;
|
||||
- 根据勾选范围、分页范围、阈值筛选规则确定最终达人集合;
|
||||
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
|
||||
- 调用公司后端接口补充秒思 api 指标;
|
||||
- 生成 CSV 文件,或组装批次 payload 提交到后端。
|
||||
|
||||
项目输出包括:
|
||||
|
||||
- 页面上新增的数据列和状态提示;
|
||||
- 下载到本地的 CSV 文件;
|
||||
- 提交到后端的达人批次;
|
||||
- 插件弹窗中的登录状态和更新状态。
|
||||
|
||||
项目依赖的外部平台、数据源或服务包括:
|
||||
|
||||
- 巨量星图网页和星图接口;
|
||||
- 公司 Logto 登录系统;
|
||||
- 公司 talent-search 后端服务;
|
||||
- 本地或内网批次提交后端;
|
||||
- COS 上的插件更新清单和安装包。
|
||||
|
||||
主要使用者是 AIGC 部门或相关业务同事。通常在以下场景使用:
|
||||
|
||||
- 在星图市场中筛选达人后,需要快速导出达人数据;
|
||||
- 已知一批星图 ID,需要批量补齐画像、效果预估和内容指标;
|
||||
- 需要把一批达人提交给后续业务系统继续处理;
|
||||
- 需要检查或更新内部插件版本。
|
||||
|
||||
## 2. 整体流程总览
|
||||
|
||||
完整主流程按真实使用顺序如下:
|
||||
|
||||
1. 安装或更新插件;
|
||||
2. 登录插件;
|
||||
3. 打开巨量星图达人市场页面;
|
||||
4. 插件读取登录状态并挂载工具栏;
|
||||
5. 插件读取当前星图达人列表,并补充页面展示指标;
|
||||
6. 使用者选择达人、字段、传播指标筛选条件或输入星图 ID;
|
||||
7. 使用者触发导出或提交批次;
|
||||
8. 插件收集达人数据,按规则过滤、去重、补充字段;
|
||||
9. 插件调用星图接口和公司后端接口补充数据;
|
||||
10. 插件下载 CSV,或把批次提交给后端;
|
||||
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
|
||||
|
||||
### 2.1 安装或更新插件
|
||||
|
||||
- 触发者:使用者或管理员。
|
||||
- 输入:内部 ZIP 安装包,或插件弹窗中发现的新版本安装包。
|
||||
- 处理:解压 ZIP,在 Chrome 扩展页加载 `dist` 文件夹;更新时下载新版 ZIP 后人工重新加载插件。
|
||||
- 输出:Chrome 中安装好的 `Star Chart Search Enhancer` 插件。
|
||||
- 下一步:登录插件。
|
||||
- 人工操作:需要人工解压、加载或重新加载插件。
|
||||
- 条件分支:如果旧版本无法检查更新,需要做一次手动过桥升级。
|
||||
|
||||
### 2.2 登录插件
|
||||
|
||||
- 触发者:使用者点击插件弹窗中的登录按钮。
|
||||
- 输入:公司账号登录态。
|
||||
- 处理:通过 Logto 完成 Chrome 扩展登录,并获取访问后端资源的 token。
|
||||
- 输出:插件弹窗显示已登录状态,内容脚本后续可以挂载业务工具栏。
|
||||
- 下一步:打开星图达人市场页面。
|
||||
- 人工操作:需要使用者完成登录。
|
||||
- 条件分支:未登录或登录过期时,星图页面不会进入导出/提交流程,只显示登录提示。
|
||||
|
||||
### 2.3 打开星图达人市场页面
|
||||
|
||||
- 触发者:使用者访问星图市场页面。
|
||||
- 输入:星图网页登录态、当前页面筛选条件、星图市场列表。
|
||||
- 处理:插件仅在 `xingtu.cn` 域名下的达人市场页面生效;进入页面后先安装页面桥接逻辑,再检查插件登录状态。
|
||||
- 输出:页面上出现插件工具栏和增强列。
|
||||
- 下一步:读取达人列表并补充数据。
|
||||
- 人工操作:使用者需要在星图网页中完成筛选、搜索、翻页或勾选。
|
||||
- 条件分支:如果页面不是星图达人市场页面,插件不启动主流程。
|
||||
|
||||
### 2.4 页面增强和数据补充
|
||||
|
||||
- 触发者:星图页面加载、翻页、列表变化或插件同步周期。
|
||||
- 输入:页面可见达人行、星图列表接口返回值、当前登录用户 token。
|
||||
- 处理:插件读取达人 ID、名称、地区、报价等基础数据,补充看后搜率列和秒思指标列。
|
||||
- 输出:页面上显示加载中、成功、失败或暂无数据等状态。
|
||||
- 下一步:使用者选择导出、按 ID 导出或提交批次。
|
||||
- 人工操作:无。
|
||||
- 条件分支:如果某个指标加载失败,只影响该达人对应指标,不影响页面整体使用。
|
||||
|
||||
### 2.5 导出选中达人数据
|
||||
|
||||
- 触发者:使用者点击 `导出选中达人数据`。
|
||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 内容。
|
||||
- 人工操作:需要使用者勾选达人并点击按钮。
|
||||
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示。
|
||||
|
||||
### 2.6 按星图 ID 导出
|
||||
|
||||
- 触发者:使用者点击 `按星图ID导出`。
|
||||
- 输入:弹窗中粘贴的达人星图 ID。
|
||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
||||
- 人工操作:需要使用者粘贴 ID 并确认。
|
||||
- 条件分支:如果没有有效 ID,不执行导出并提示。
|
||||
|
||||
### 2.7 提交批次
|
||||
|
||||
- 触发者:使用者点击 `提交批次`。
|
||||
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
|
||||
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标阈值筛选和选中规则,检查登录状态,组装批次 payload,提交到后端。
|
||||
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
|
||||
- 下一步:在后端系统中继续处理批次。
|
||||
- 人工操作:需要使用者输入批次名称。
|
||||
- 条件分支:未登录、批次名为空、后端拒绝或接口失败都会导致本次提交失败。
|
||||
|
||||
## 3. 详细流程说明
|
||||
|
||||
### 3.1 插件安装与更新
|
||||
|
||||
- 步骤目的:让使用者在 Chrome 中获得可运行的内部插件。
|
||||
- 输入内容:内部发布 ZIP、安装说明 PDF、Chrome 浏览器。
|
||||
- 处理规则:
|
||||
- 首次安装需要解压 ZIP;
|
||||
- Chrome 加载的是解压后的 `dist` 文件夹;
|
||||
- 更新时仍然需要人工下载、解压并重新加载;
|
||||
- 扩展 ID 应为 `pkjopdibdnomhogjheclhnknmejccffg`。
|
||||
- 输出结果:Chrome 扩展列表中出现正确插件。
|
||||
- 外部依赖:Chrome 扩展能力;COS 更新文件。
|
||||
- 失败后如何处理:安装失败不会影响外部数据;使用者无法进入后续流程。
|
||||
- 是否影响后续步骤:影响。未安装或安装版本不正确时,后续导出和提交不可用。
|
||||
|
||||
### 3.2 插件登录
|
||||
|
||||
- 步骤目的:获得访问公司后端和受保护接口所需的用户身份。
|
||||
- 输入内容:公司登录账号、Logto 登录配置。
|
||||
- 处理规则:
|
||||
- 登录通过 Chrome identity 回调完成;
|
||||
- 插件读取用户 `sub`、用户名、资源地址和 scope;
|
||||
- 内容脚本进入星图页面时会先读取登录状态。
|
||||
- 输出结果:已登录状态、可用 access token。
|
||||
- 外部依赖:Logto 登录系统。
|
||||
- 失败后如何处理:星图页面显示登录提示;不挂载业务工具栏。
|
||||
- 是否影响后续步骤:影响。批次提交和后端指标查询都依赖 token。
|
||||
|
||||
### 3.3 星图市场页面启动
|
||||
|
||||
- 步骤目的:只在正确页面启用插件能力。
|
||||
- 输入内容:当前浏览器 URL、页面 DOM、星图页面列表请求。
|
||||
- 处理规则:
|
||||
- 只匹配巨量星图达人市场页面;
|
||||
- 进入页面后先安装桥接逻辑,用于捕获星图市场列表请求和页面列表数据;
|
||||
- 未登录时不进入业务控制流程;
|
||||
- 已登录时挂载工具栏和新增列。
|
||||
- 输出结果:插件工具栏、选择框、增强数据列。
|
||||
- 外部依赖:星图网页结构和浏览器内容脚本能力。
|
||||
- 失败后如何处理:如果页面结构变化导致无法挂载,相关功能不可用;未确认是否有统一错误上报。
|
||||
- 是否影响后续步骤:影响。工具栏未挂载时无法导出或提交。
|
||||
|
||||
### 3.4 读取达人列表
|
||||
|
||||
- 步骤目的:确定当前页面或导出范围内有哪些达人。
|
||||
- 输入内容:星图页面列表行、星图列表接口返回值、当前分页状态。
|
||||
- 处理规则:
|
||||
- 优先从星图市场列表接口返回中读取达人;
|
||||
- 关键字段包括达人 ID、达人名称、星图内部传播接口 ID、核心用户 ID、地区、报价和页面可导出字段;
|
||||
- 如果没有捕获到接口请求,则从页面 DOM 读取可见行;
|
||||
- 页面翻页导出时等待页面稳定后再读取;
|
||||
- 同一个达人重复出现时按达人 ID 合并。
|
||||
- 输出结果:标准化后的达人记录集合。
|
||||
- 外部依赖:星图市场列表接口和页面 DOM。
|
||||
- 失败后如何处理:
|
||||
- 单页加载超时会终止本次范围导出;
|
||||
- 单条达人缺少 ID 或名称会被跳过;
|
||||
- 捕获接口失败时退回页面翻页读取。
|
||||
- 是否影响后续步骤:影响。没有达人记录时,导出或提交结果为空或失败。
|
||||
|
||||
### 3.5 页面指标补充
|
||||
|
||||
- 步骤目的:在列表页直接显示补充指标,方便筛选和排序。
|
||||
- 输入内容:当前页面达人 ID。
|
||||
- 处理规则:
|
||||
- 看后搜率优先使用星图列表中已有值;
|
||||
- 如果列表值不完整,再调用星图看后搜率相关接口;
|
||||
- 秒思指标按当前页达人 ID 批量查询后端;
|
||||
- 已成功或已判定缺失的后端指标在当前页面会话中不重复查询;
|
||||
- 指标列支持页面内排序。
|
||||
- 输出结果:页面增强列显示成功值、加载中、加载失败或暂无数据。
|
||||
- 外部依赖:星图接口、公司后端指标接口。
|
||||
- 失败后如何处理:单个达人指标失败只显示失败,不阻塞其他达人。
|
||||
- 是否影响后续步骤:部分影响。导出时会复用已缓存指标,缺失时可能再次补充。
|
||||
|
||||
### 3.6 导出选中达人数据
|
||||
|
||||
- 步骤目的:把使用者选定的达人数据导出为 CSV。
|
||||
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
||||
- 处理规则:
|
||||
- 必须先勾选达人;
|
||||
- 先按导出范围收集达人;
|
||||
- 再严格保留当前导出范围内已勾选的达人;
|
||||
- 对每个达人补充画像、商业能力、传播指标、看后搜率、秒思指标;
|
||||
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
|
||||
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败;
|
||||
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
||||
- 输出结果:CSV 文件。
|
||||
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
||||
- 失败后如何处理:
|
||||
- 没有勾选达人:提示并停止;
|
||||
- 当前范围内无选中达人:提示并停止;
|
||||
- 单个达人部分失败:记录为部分成功或失败;
|
||||
- 全部画像失败:不下载 CSV。
|
||||
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
||||
|
||||
### 3.7 按星图 ID 导出
|
||||
|
||||
- 步骤目的:在不依赖当前星图列表勾选的情况下,批量导出指定 ID 的达人数据。
|
||||
- 输入内容:使用者粘贴的星图 ID 文本。
|
||||
- 处理规则:
|
||||
- 支持用空格、换行、英文逗号、中文逗号、英文分号、中文分号分隔;
|
||||
- 只接受 16 到 20 位纯数字;
|
||||
- 重复 ID 会去重;
|
||||
- 非法 token 会计入提示,但不会进入导出;
|
||||
- 对有效 ID 逐个补齐基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标;
|
||||
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
||||
- 输出结果:按 ID 导出的 CSV 文件。
|
||||
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
||||
- 失败后如何处理:
|
||||
- 没有有效 ID:提示并停止;
|
||||
- 单个 ID 的部分接口失败:保留该行并写失败原因;
|
||||
- 整体流程异常:提示按 ID 导出失败。
|
||||
- 是否影响后续步骤:不写入外部系统,不影响批次。
|
||||
|
||||
### 3.8 传播指标阈值筛选
|
||||
|
||||
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
|
||||
- 输入内容:视频类别、是否只看指派、是否排除营销流量、时间范围、七个指标阈值。
|
||||
- 处理规则:
|
||||
- 没有填写任何阈值时,不启用该筛选;
|
||||
- 填写多个阈值时必须全部满足;
|
||||
- 个人视频固定为不限指派、不排除营销流量;
|
||||
- 星图视频可选择只看指派、不限指派、排除营销流量或不排除营销流量;
|
||||
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`;
|
||||
- 平均时长按秒比较;
|
||||
- 播放量、评论、点赞、转发按普通数字比较;
|
||||
- 请求失败或缺少被启用指标的达人视为不满足筛选。
|
||||
- 输出结果:过滤后的达人集合。
|
||||
- 外部依赖:星图传播指标接口。
|
||||
- 失败后如何处理:
|
||||
- 阈值非法:阻止导出或提交并提示;
|
||||
- 单个达人筛选请求失败:跳过该达人;
|
||||
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
|
||||
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
|
||||
|
||||
### 3.9 批次提交
|
||||
|
||||
- 步骤目的:把达人集合提交给后续业务系统。
|
||||
- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称、登录用户信息。
|
||||
- 处理规则:
|
||||
- 点击后先输入批次名称;
|
||||
- 取消输入则停止;
|
||||
- 批次名为空则提示并停止;
|
||||
- 如果存在已勾选达人,则优先提交当前范围内已勾选达人;
|
||||
- 如果没有勾选达人,则提交当前范围内所有达人;
|
||||
- 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人;
|
||||
- 前端不生成批次 ID,批次 ID 由后端生成;
|
||||
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
|
||||
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
|
||||
- 输出结果:后端批次记录;页面提示提交成功或失败。
|
||||
- 外部依赖:Logto token、批次提交后端。
|
||||
- 失败后如何处理:
|
||||
- 未登录:提示请先登录插件;
|
||||
- token 不可用:提交失败;
|
||||
- 401 或 403:提交失败并返回未授权错误;
|
||||
- 后端返回非成功:提交失败并显示错误;
|
||||
- 网络失败:提交失败。
|
||||
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
|
||||
|
||||
### 3.10 CSV 下载
|
||||
|
||||
- 步骤目的:把导出结果交付给使用者。
|
||||
- 输入内容:生成好的 CSV 字符串和文件名。
|
||||
- 处理规则:
|
||||
- 优先通过 Chrome 扩展后台下载;
|
||||
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
||||
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
||||
- 普通导出文件名使用插件名加时间戳;
|
||||
- 按 ID 导出文件名包含按 ID 导出标识。
|
||||
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
||||
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
||||
- 失败后如何处理:下载失败会提示;已生成数据不会写入外部系统。
|
||||
- 是否影响后续步骤:不影响外部系统。
|
||||
|
||||
## 4. 接口和外部服务说明
|
||||
|
||||
| 接口/服务 | 用途 | 使用环节 | 核心入参 | 核心返回 | 分页 | 限流 | 并发控制 | 超时 | 重试机制 | 重试次数 | 会重试的情况 | 不会重试的情况 | 凭证/权限 | 额度/成本 | 失败处理 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 巨量星图网页 | 提供达人市场页面和当前筛选结果 | 页面启动、读取达人列表、人工筛选 | 星图网页登录态、页面筛选条件 | 达人列表页面 | 通过页面分页 | 未确认 | 页面翻页串行执行 | 页面翻页等待约 3 秒,页面稳定等待约 12 秒 | 无自动重试 | 0 | 无 | 页面结构异常、未登录、加载失败 | 星图账号和 cookie | 未确认 | 页面不匹配或结构异常时插件功能不可用 |
|
||||
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
|
||||
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id`、`range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
|
||||
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id`、`range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
|
||||
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 单个达人内画像和商业能力并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因;全部画像失败时不下载选中导出 CSV |
|
||||
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
|
||||
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
|
||||
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 与画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
|
||||
| `get_author_spread_info` | 获取内容传播指标,并用于阈值筛选 | 内容数据导出、阈值筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败时该达人不满足筛选 |
|
||||
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id`、`values`、`page=1`、`size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
|
||||
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
|
||||
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
|
||||
| COS 更新清单 | 检查插件新版本 | 插件弹窗 | `latest.json` URL | 最新版本、ZIP URL、说明 PDF URL、发布时间、更新说明 | 否 | 未确认 | 弹窗打开后单次检查 | 未确认 | 无自动重试 | 0 | 无 | 请求失败、清单格式错误、URL 非 HTTPS | 清单和安装包需公开可读 | COS 存储和流量成本未确认 | 检查失败时弹窗显示无法检查更新,不影响已安装插件主功能 |
|
||||
| Chrome downloads | 下载 CSV、更新包和说明 PDF | CSV 导出、插件更新 | 文件名、下载 URL 或 data URL | 浏览器下载任务 | 否 | 浏览器自身规则,未确认 | 由浏览器管理 | 未确认 | 无自动重试 | 0 | 无 | 下载权限不可用、浏览器拦截、URL 无效 | Chrome `downloads` 权限 | 未确认 | CSV 下载失败时回退页面链接下载;更新包下载失败时显示错误 |
|
||||
|
||||
凭证和权限说明:
|
||||
|
||||
- 星图接口依赖当前浏览器中的星图网页登录态和 cookie;
|
||||
- 公司后端指标查询和批次提交依赖 Logto access token;
|
||||
- 插件需要 Chrome `downloads`、`identity`、`storage` 权限;
|
||||
- COS 更新包需要公开可读;
|
||||
- 具体接口额度、调用成本、账号级限制均未确认。
|
||||
|
||||
## 5. 数据处理规则
|
||||
|
||||
### 5.1 数据来源
|
||||
|
||||
数据主要来自四类来源:
|
||||
|
||||
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
|
||||
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
|
||||
- 公司 talent-search 后端:秒思 api 指标;
|
||||
- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和阈值筛选条件。
|
||||
|
||||
### 5.2 保留规则
|
||||
|
||||
- 有有效达人 ID 和达人名称的记录会进入页面记录集合;
|
||||
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
|
||||
- CSV 基础字段、导出状态、失败原因等固定字段会保留;
|
||||
- 字段选择只影响可选数据字段,不删除固定标识字段。
|
||||
|
||||
### 5.3 过滤规则
|
||||
|
||||
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
|
||||
- 导出选中达人数据必须有已勾选达人;
|
||||
- 画像导出只保留当前导出范围内的已勾选达人;
|
||||
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
|
||||
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
|
||||
- 传播指标阈值筛选启用后,不满足全部阈值的达人会被过滤;
|
||||
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
||||
|
||||
### 5.4 去重规则
|
||||
|
||||
- 多页导出和后台分页导出按达人 ID 合并去重;
|
||||
- 按 ID 导出对输入 ID 去重;
|
||||
- 合并时优先保留已有的非空字段;
|
||||
- 指标字段会在有新非空值时补充。
|
||||
|
||||
### 5.5 字段补充和合并规则
|
||||
|
||||
- 星图列表数据作为基础;
|
||||
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
||||
- 秒思指标按 star_id 从后端补充;
|
||||
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
||||
- `代表视频`可能被读取,但不会进入最终普通市场 CSV;
|
||||
- 画像和商业能力字段追加在基础字段之后;
|
||||
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
||||
|
||||
### 5.6 覆盖规则
|
||||
|
||||
- 当前页面会话内的内存记录会被补充和合并;
|
||||
- 项目不会把 CSV 导出结果写回星图;
|
||||
- 批次提交会向后端创建或提交数据,是否覆盖已有批次未确认;
|
||||
- 字段选择配置会保存到浏览器 localStorage,下次导出沿用。
|
||||
|
||||
### 5.7 写入位置
|
||||
|
||||
- CSV 写入浏览器下载目录;
|
||||
- 批次数据写入批次提交后端;
|
||||
- 字段选择写入浏览器 localStorage;
|
||||
- 登录状态由 Logto Chrome 扩展 SDK 管理;
|
||||
- 项目本身不维护持久任务数据库。
|
||||
|
||||
### 5.8 写入失败处理
|
||||
|
||||
- CSV 下载失败会提示或使用备用下载方式;
|
||||
- 批次提交失败会提示失败原因,本次提交不视为成功;
|
||||
- 字段选择保存失败会被忽略,后续可能恢复默认全选字段;
|
||||
- 后端指标补充失败不会阻止 CSV 生成,只会导致字段为空。
|
||||
|
||||
### 5.9 重复运行时数据变化
|
||||
|
||||
- 重复导出会重新生成新的 CSV 文件;
|
||||
- 重复按 ID 导出不会写入外部业务系统;
|
||||
- 重复提交批次可能在后端产生重复批次,是否由后端去重未确认;
|
||||
- 页面内已缓存的成功指标可能在当前会话中复用,刷新页面后会重新读取。
|
||||
|
||||
## 6. 重复执行、中断恢复和幂等性
|
||||
|
||||
- 任务可以重复执行。
|
||||
- CSV 导出重复执行会产生新的下载文件,不会覆盖星图或后端数据。
|
||||
- 按 ID 导出重复执行会重新请求接口并生成新 CSV,不会写入后端批次。
|
||||
- 字段选择重复保存会覆盖浏览器本地保存的字段选择。
|
||||
- 批次提交重复执行是否会重复创建批次:未确认。当前前端没有批次幂等键,也不生成 batchId。
|
||||
- 批次提交是否覆盖已有结果:未确认,取决于后端。
|
||||
- 任务跑到一半失败后,当前项目没有持久任务状态记录。
|
||||
- 导出中断后再次执行会从本次流程开头重新收集和请求,不会从上次中断点恢复。
|
||||
- 页面会话中的指标缓存可以减少同一页面内重复请求,但不等同于断点续跑。
|
||||
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
|
||||
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
|
||||
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
|
||||
- 阈值筛选没有持久状态,重新执行时按当前页面输入框值重新判断。
|
||||
|
||||
重复执行相对安全的操作:
|
||||
|
||||
- 重新打开页面;
|
||||
- 重新导出 CSV;
|
||||
- 重新按 ID 导出;
|
||||
- 重新检查更新;
|
||||
- 重新保存字段选择。
|
||||
|
||||
重复执行可能有风险的操作:
|
||||
|
||||
- 重复点击提交批次;
|
||||
- 修改后端地址后提交批次;
|
||||
- 使用不同星图筛选条件或不同字段选择重复导出后,拿多个 CSV 混用;
|
||||
- 阈值输入为空或变化后重复提交,可能导致提交达人集合变化。
|
||||
|
||||
未确认项:
|
||||
|
||||
- 后端批次接口是否按批次名称、用户或达人集合去重;
|
||||
- 后端批次接口是否允许空达人列表;
|
||||
- 后端是否有任务状态、失败重试或重复提交保护;
|
||||
- Logto token 刷新失败后是否有 SDK 内部重试。
|
||||
|
||||
## 7. 项目使用方式
|
||||
|
||||
### 7.1 使用前准备
|
||||
|
||||
使用前需要准备:
|
||||
|
||||
- Google Chrome 浏览器;
|
||||
- 内部发布的 `star-chart-search-enhancer-internal.zip`;
|
||||
- 可访问巨量星图的账号;
|
||||
- 可访问公司 Logto 登录系统的账号;
|
||||
- 如果要提交批次,需要批次提交后端可访问;
|
||||
- 如果要查询秒思 api 指标,需要 talent-search 后端授权可用。
|
||||
|
||||
需要的权限和凭证:
|
||||
|
||||
- Chrome 扩展加载权限;
|
||||
- 星图网页登录态;
|
||||
- Logto 登录态;
|
||||
- talent-search 后端读取权限;
|
||||
- 批次提交后端所需权限,具体 scope 是否只需当前配置未确认。
|
||||
|
||||
### 7.2 本地安装使用
|
||||
|
||||
1. 解压内部 ZIP;
|
||||
2. 打开 `chrome://extensions`;
|
||||
3. 开启开发者模式;
|
||||
4. 点击加载已解压的扩展程序;
|
||||
5. 选择解压后的 `dist` 文件夹;
|
||||
6. 确认扩展 ID 为 `pkjopdibdnomhogjheclhnknmejccffg`;
|
||||
7. 固定插件图标;
|
||||
8. 点击插件图标并登录;
|
||||
9. 打开 `https://xingtu.cn/ad/creator/market`;
|
||||
10. 等待插件工具栏出现。
|
||||
|
||||
### 7.3 执行一次完整导出任务
|
||||
|
||||
1. 在星图市场中完成筛选;
|
||||
2. 等待页面列表加载完成;
|
||||
3. 勾选需要导出的达人;
|
||||
4. 可选:点击 `选择字段` 调整 CSV 字段;
|
||||
5. 可选:填写传播指标阈值;
|
||||
6. 点击 `导出选中达人数据`;
|
||||
7. 等待状态提示从导出中消失或浏览器下载完成;
|
||||
8. 在下载目录或 Chrome 下载列表中查看 CSV;
|
||||
9. 检查 `导出状态` 和 `失败原因` 字段。
|
||||
|
||||
### 7.4 按 ID 执行导出任务
|
||||
|
||||
1. 点击 `按星图ID导出`;
|
||||
2. 粘贴达人星图 ID,每行一个或用分隔符隔开;
|
||||
3. 点击确认;
|
||||
4. 查看识别数量、去重后数量和非法数量提示;
|
||||
5. 等待 CSV 下载;
|
||||
6. 检查每行导出状态和失败原因。
|
||||
|
||||
### 7.5 执行一次批次提交
|
||||
|
||||
1. 在星图市场中完成筛选;
|
||||
2. 可选:勾选需要提交的达人;
|
||||
3. 可选:填写传播指标阈值;
|
||||
4. 点击 `提交批次`;
|
||||
5. 输入批次名称;
|
||||
6. 等待页面提示 `批次提交成功`;
|
||||
7. 到后端系统确认批次是否生成。
|
||||
|
||||
### 7.6 只执行某个子流程
|
||||
|
||||
- 只登录:打开插件弹窗并登录;
|
||||
- 只检查更新:登录后打开插件弹窗查看版本更新区域;
|
||||
- 只选择字段:在星图市场页点击 `选择字段` 并保存;
|
||||
- 只按 ID 导出:不需要勾选页面达人,直接点击 `按星图ID导出`;
|
||||
- 只提交批次:不需要先导出 CSV,但需要登录并输入批次名称。
|
||||
|
||||
### 7.7 重新执行任务
|
||||
|
||||
- 重新导出:直接再次点击导出按钮;
|
||||
- 重新按 ID 导出:再次打开 ID 输入弹窗并确认;
|
||||
- 重新提交批次:再次点击提交批次并输入批次名称。注意可能创建重复批次,后端幂等未确认;
|
||||
- 页面异常后重试:刷新星图页面,等待工具栏重新出现后再执行。
|
||||
|
||||
### 7.8 确认任务完成
|
||||
|
||||
- 导出任务:浏览器下载列表出现 CSV 文件;
|
||||
- 按 ID 导出:CSV 文件名包含按 ID 导出标识,且文件中有导出状态列;
|
||||
- 批次提交:页面显示 `批次提交成功`,并在后端系统中能看到对应批次;
|
||||
- 插件更新:重新加载后插件版本显示为新版本。
|
||||
|
||||
### 7.9 危险操作
|
||||
|
||||
- 重复点击 `提交批次`;
|
||||
- 修改后端地址后未验证就发给同事使用;
|
||||
- 删除正在被 Chrome 加载的 `dist` 文件夹;
|
||||
- 随意修改 Logto 配置、后端地址、scope 或 manifest key;
|
||||
- 在未确认星图筛选条件的情况下提交全部范围达人;
|
||||
- 阈值筛选填错导致提交集合被大幅改变。
|
||||
|
||||
### 7.10 不能随便改的参数
|
||||
|
||||
- 固定扩展 ID 相关配置;
|
||||
- Logto appId、endpoint、resource、scope;
|
||||
- 批次提交后端地址;
|
||||
- talent-search 后端地址;
|
||||
- COS 更新清单 URL;
|
||||
- 星图接口参数含义;
|
||||
- 传播指标列名规则;
|
||||
- 批次 payload 字段。
|
||||
|
||||
### 7.11 运行和发布方式
|
||||
|
||||
本地开发运行:
|
||||
|
||||
- 安装依赖:`npm install`;
|
||||
- 运行测试:`npm test`;
|
||||
- 开发构建:`npm run build`;
|
||||
- 然后在 Chrome 中加载 `dist`。
|
||||
|
||||
内部发布构建:
|
||||
|
||||
- 运行测试;
|
||||
- 执行内部打包;
|
||||
- 生成 ZIP 和更新清单;
|
||||
- 上传或分发给同事;
|
||||
- 同事仍需人工解压和加载。
|
||||
|
||||
定时任务运行:
|
||||
|
||||
- 当前项目未确认存在服务端定时任务。
|
||||
- 插件更新发布可通过 tag 触发 Drone 发布流程。
|
||||
|
||||
## 8. 运行参数和配置说明
|
||||
|
||||
| 配置名称 | 作用 | 默认值 | 可选值 | 修改影响 | 是否需要重启/重载 | 风险 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 扩展 ID / manifest key | 固定 Chrome 扩展身份 | `pkjopdibdnomhogjheclhnknmejccffg` | 未确认 | 影响 Logto 回调、用户安装识别、更新连续性 | 需要重新构建并重新加载插件 | 改错会导致登录失败或同事装到不同插件 |
|
||||
| Logto endpoint | 登录服务地址 | `https://login-api.intelligrow.cn` | 未确认 | 影响登录和 token 获取 | 需要重新构建并重新加载插件 | 登录不可用 |
|
||||
| Logto appId | 插件登录应用 ID | `i4jkllbvih0554r4n0fd3` | 未确认 | 影响登录应用和回调校验 | 需要重新构建并重新加载插件 | 登录不可用 |
|
||||
| apiResource | token 资源地址 | `https://talent-search.intelligrow.cn` | 未确认 | 影响后端 token audience/resource | 需要重新构建并重新加载插件 | 后端接口 401/403 |
|
||||
| scopes | 登录申请权限 | `openid`、`profile`、`offline_access`、`talent-search:read` | 未确认 | 影响 token 权限 | 需要重新登录;通常也需重新构建 | 后端读写权限不足 |
|
||||
| enableDevAuthPanel | 是否显示开发调试面板 | `false` | `true` / `false` | 影响插件弹窗是否显示调试入口 | 需要重新构建并重新加载插件 | 暴露调试入口 |
|
||||
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 |
|
||||
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 |
|
||||
| COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
|
||||
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
|
||||
| 传播指标阈值 | 导出或提交前二次过滤达人 | 空 | 非负数字 | 影响最终保留达人集合 | 不需要重启 | 填错会过滤掉目标达人 |
|
||||
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
|
||||
|
||||
## 9. 任务执行和结果确认
|
||||
|
||||
### 9.1 任务开始标志
|
||||
|
||||
- 插件登录任务:点击插件弹窗登录按钮后跳转登录;
|
||||
- 页面增强任务:星图市场页面出现插件工具栏和新增列;
|
||||
- 导出任务:状态区出现 `画像导出中`、`按ID画像导出中` 或类似导出中提示;
|
||||
- 批次提交任务:点击提交并输入批次名称后,状态区出现提交中提示;
|
||||
- 更新检查任务:插件弹窗显示正在检查更新。
|
||||
|
||||
### 9.2 执行中状态
|
||||
|
||||
- 工具栏按钮会被禁用,防止同一流程中重复点击;
|
||||
- 多页收集时会显示页码进度;
|
||||
- 画像和按 ID 导出会显示当前处理序号;
|
||||
- 页面指标列可能显示 `加载中...`;
|
||||
- 后端指标可能显示暂无数据或加载失败。
|
||||
|
||||
### 9.3 成功完成标志
|
||||
|
||||
- CSV 导出:浏览器下载列表出现 CSV 文件;
|
||||
- 按 ID 导出:下载完成,CSV 中每行有导出状态;
|
||||
- 批次提交:页面提示 `批次提交成功`;
|
||||
- 更新下载:弹窗提示已触发下载;
|
||||
- 页面增强:指标列显示具体数值或明确的暂无数据状态。
|
||||
|
||||
### 9.4 部分成功表现
|
||||
|
||||
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
||||
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
||||
- 传播指标某组参数失败时,对应字段为空;
|
||||
- 画像部分失败时,其他画像或商业能力字段仍可保留;
|
||||
- 后端指标查询不到某个达人时显示暂无数据。
|
||||
|
||||
### 9.5 失败表现
|
||||
|
||||
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
||||
- 没勾选就导出选中达人数据:提示请先勾选;
|
||||
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
|
||||
- 全部画像失败:提示画像导出失败,不下载 CSV;
|
||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
||||
- 更新清单失败:弹窗显示暂时无法检查更新或错误信息。
|
||||
|
||||
### 9.6 最终结果查看位置
|
||||
|
||||
- CSV:浏览器默认下载目录或 Chrome 下载列表;
|
||||
- 批次:批次提交后端系统,具体查看入口未确认;
|
||||
- 插件版本:插件弹窗或 Chrome 扩展详情页;
|
||||
- 登录状态:插件弹窗;
|
||||
- 页面增强结果:星图达人市场页面新增列。
|
||||
|
||||
### 9.7 管理者确认标准
|
||||
|
||||
管理者确认一次任务是否达到预期时,应关注:
|
||||
|
||||
- 使用者是否登录了正确插件;
|
||||
- 星图筛选条件是否符合业务目标;
|
||||
- 导出的 CSV 行数是否符合已选达人或输入 ID 数量;
|
||||
- CSV 中 `导出状态` 是否大部分为成功;
|
||||
- 关键业务字段是否有值,例如内容数据、效果预估、画像、秒思 api 数据;
|
||||
- 批次提交是否在后端生成对应批次;
|
||||
- 批次名称、创建人和达人数量是否符合预期;
|
||||
- 是否存在重复提交批次。
|
||||
|
||||
## 10. 重要限制和风险
|
||||
|
||||
- 星图接口调用额度限制:未确认。
|
||||
- 星图接口限流规则:未确认。
|
||||
- 公司后端接口额度限制:未确认。
|
||||
- 批次提交接口幂等规则:未确认。
|
||||
- 项目没有持久任务状态记录,不支持真正断点续跑。
|
||||
- 导出范围过大时,会产生大量星图接口请求,运行时间会变长。
|
||||
- 当前传播指标补充和筛选存在并发请求;是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
|
||||
- 星图页面结构变化可能导致工具栏挂载、列表读取或翻页失效。
|
||||
- 星图网页登录态过期会导致接口失败。
|
||||
- Logto token 不可用会导致后端指标和批次提交失败。
|
||||
- 批次提交重复执行可能产生重复批次。
|
||||
- 修改后端地址可能把数据提交到错误环境。
|
||||
- 字段选择保存到本地浏览器,换浏览器或清理数据后会恢复默认。
|
||||
- 更新包仍需人工解压和重载,下载新版本不等于插件已更新。
|
||||
- 删除或移动本地 `dist` 文件夹会导致已加载插件失效。
|
||||
- 扩展 ID、Logto 回调和 manifest key 强相关,改错会导致登录失败。
|
||||
- `http://localhost:8083` 作为批次提交默认地址时,只适合本机后端可用的场景;生产或同事环境是否适用未确认。
|
||||
- 下载 CSV 不会自动校验业务完整性,需要使用者或管理者检查导出状态和关键字段。
|
||||
- 传播指标阈值填错会改变导出或提交达人集合。
|
||||
|
||||
## 11. 未确认项清单
|
||||
|
||||
- 星图各接口是否存在明确限流:未确认。
|
||||
- 星图各接口账号级、IP 级或 cookie 级调用额度:未确认。
|
||||
- 星图各接口失败后是否由浏览器或服务端内部重试:未确认。
|
||||
- 公司后端 `history/talents/search` 是否有分页上限、查询数量上限或限流:未确认。
|
||||
- 公司后端 `history/talents/search` 的接口超时时间:未确认。
|
||||
- 批次提交后端的生产地址:未确认;当前工作区配置为 `http://localhost:8083`。
|
||||
- 批次提交后端是否支持幂等:未确认。
|
||||
- 批次提交后端是否允许空达人列表:未确认。
|
||||
- 批次提交后端是否会覆盖同名批次:未确认。
|
||||
- 批次提交后端生成的 7 位数字批次 ID 的具体规则:未确认。
|
||||
- 批次提交后端的查看入口和管理流程:未确认。
|
||||
- 当前 Logto scope 是否足够覆盖批次写操作:未确认。
|
||||
- COS 更新清单的权限、缓存和发布审核规则:未确认。
|
||||
- Drone 发布是否为唯一正式发布方式:未确认。
|
||||
- 插件是否有统一日志、错误上报或审计记录:未确认。
|
||||
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
|
||||
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。
|
||||
- 传播指标请求是否应该限制并发:需求文档曾提出需要限制,但当前真实业务级并发控制未确认。
|
||||
- 后续业务系统如何消费批次:未确认。
|
||||
|
||||
## 12. 文档维护规则
|
||||
|
||||
从现在开始,任何模型或工程师在修改本项目代码时,都必须遵守以下规则:
|
||||
|
||||
1. 修改代码前,先检查本流程文档是否描述了相关流程;
|
||||
2. 如果代码改动影响流程、接口、配置、数据处理规则、使用方式、任务执行方式、结果确认方式、限流、重试、超时、并发或幂等性,必须同步更新本文档;
|
||||
3. 如果代码行为和文档描述发生冲突,必须以当前真实行为为准更新文档;
|
||||
4. 如果改动较大,但判断不需要更新文档,必须说明原因;
|
||||
5. 每次完成较大代码改动后,最终回复必须明确说明:
|
||||
- 是否检查了流程文档;
|
||||
- 是否更新了流程文档;
|
||||
- 更新了哪些部分;
|
||||
- 如果没有更新,为什么不需要更新。
|
||||
|
||||
以下情况都视为必须检查文档的较大改动:
|
||||
|
||||
- 改变整体流程顺序;
|
||||
- 新增、删除或调整流程步骤;
|
||||
- 新增、删除或替换外部接口;
|
||||
- 修改接口参数、鉴权方式、分页方式、限流方式、重试方式、超时规则或并发规则;
|
||||
- 修改数据过滤、去重、合并、覆盖、写入规则;
|
||||
- 修改任务启动方式、运行参数或配置项;
|
||||
- 修改任务成功、失败、部分成功的判断方式;
|
||||
- 修改重复执行、中断恢复或幂等性行为;
|
||||
- 修改最终结果的产出位置或格式;
|
||||
- 修改会影响项目使用者操作方式的任何内容。
|
||||
@ -1,466 +0,0 @@
|
||||
# Logto 登录集成设计
|
||||
|
||||
日期:2026-04-21
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前项目是一个 Chrome MV3 扩展,主要运行在星图市场页面,通过内容脚本注入页面并提供筛选、排序、导出等增强能力。
|
||||
|
||||
下一阶段需要为插件补齐登录能力,接入 Logto,目标是:
|
||||
|
||||
- 让插件具备统一的登录态
|
||||
- 登录后可为未来的业务后端 API 获取 access token
|
||||
- 未登录时禁止使用当前插件能力
|
||||
- 这一版先打通登录、登出、会话持久化和 token 获取链路,不接真实业务后端
|
||||
|
||||
## 2. 已确认的产品决策
|
||||
|
||||
- 登录入口放在扩展 `popup`
|
||||
- 目标范围是“插件登录态 + 为未来后端 API 预留 access token 获取能力”
|
||||
- 当前版本暂不接真实后端 API
|
||||
- 未登录时,插件功能整体禁用
|
||||
- 仅允许内部已有 Logto 账号成员登录,不开放注册
|
||||
- `popup` 默认显示简洁账号信息,开发模式下显示调试信息
|
||||
- 未登录时,市场页禁用面板需要提供“去登录”引导
|
||||
- 配置项先使用占位值,不在设计文档里写死真实 tenant / appId / resource
|
||||
- 设计文档明确建议新增目录结构,而不是只写抽象功能
|
||||
|
||||
## 3. 官方文档约束
|
||||
|
||||
本设计基于以下 Logto 官方文档:
|
||||
|
||||
- Chrome 扩展快速开始:<https://docs.logto.io/quick-starts/chrome-extension>
|
||||
- Global API resources:<https://docs.logto.io/zh-CN/authorization/global-api-resources>
|
||||
|
||||
从官方文档抽取的关键约束如下:
|
||||
|
||||
- Chrome 扩展应使用适配扩展场景的登录流程,重定向 URI 基于 `chrome.identity.getRedirectURL()`
|
||||
- Manifest 需要增加 `identity`、`storage` 等权限
|
||||
- 登录流程更适合由后台脚本统一承接,而不是由内容脚本直接执行
|
||||
- access token 应面向一个明确的 API resource,而不是把“登录成功”与“可访问后端 API”混为一谈
|
||||
|
||||
这些约束直接决定了当前项目不能把认证逻辑塞进现有内容脚本入口。
|
||||
|
||||
## 4. 目标与非目标
|
||||
|
||||
### 4.1 目标
|
||||
|
||||
- 在扩展 `popup` 中提供登录和登出入口
|
||||
- 在 `background service worker` 中统一处理 Logto 认证
|
||||
- 在内容脚本中基于登录态控制插件可用性
|
||||
- 登录成功后可读取基础用户信息
|
||||
- 为未来业务后端 API 预留 `getAccessToken(resource, scopes)` 能力
|
||||
- 通过 TDD 落地实现,保证认证状态切换和权限门控可回归测试
|
||||
|
||||
### 4.2 非目标
|
||||
|
||||
- 当前版本不接真实业务后端 API
|
||||
- 当前版本不支持开放注册
|
||||
- 当前版本不做复杂账号中心页面
|
||||
- 当前版本不要求多标签页实时广播登录态变化
|
||||
- 当前版本不默认展示完整 token 明文
|
||||
|
||||
## 5. 推荐架构
|
||||
|
||||
采用方案:`Background 认证中枢 + Popup 展示 + Content 只消费状态`
|
||||
|
||||
### 5.1 模块边界
|
||||
|
||||
#### `src/background/auth/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 初始化 Logto 客户端
|
||||
- 执行 `signIn`
|
||||
- 执行 `signOut`
|
||||
- 执行 `getAuthState`
|
||||
- 执行 `getAccessToken`
|
||||
- 统一收敛认证错误
|
||||
|
||||
约束:
|
||||
|
||||
- 它是唯一直接接触 Logto SDK 和登录流程的层
|
||||
- 内容脚本和 popup 都不直接复制认证流程
|
||||
|
||||
#### `src/popup/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 作为唯一登录入口
|
||||
- 展示未登录态、已登录态、错误态
|
||||
- 在开发模式下显示调试信息
|
||||
- 向 background 发送认证消息
|
||||
|
||||
#### `src/content/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面启动时查询认证状态
|
||||
- 未登录时渲染禁用态工具栏
|
||||
- 已登录后再初始化现有筛选、排序、导出能力
|
||||
|
||||
约束:
|
||||
|
||||
- 不直接依赖 Logto SDK
|
||||
- 不自己发起登录
|
||||
- 不长期缓存 access token
|
||||
|
||||
#### `src/shared/auth-messages.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 定义 popup / content / background 之间的消息协议
|
||||
- 避免认证消息散落在多个文件中
|
||||
|
||||
### 5.2 为什么这样分层
|
||||
|
||||
- 符合 Logto 官方 Chrome 扩展接法
|
||||
- 符合 MV3 生命周期特征,避免把认证流程绑死在不稳定的内容脚本生命周期上
|
||||
- 后续接业务后端时只需要扩展 background 的 token 获取逻辑
|
||||
- 权限边界清晰,未登录时不会出现“部分功能还能偷偷使用”的行为
|
||||
|
||||
## 6. 推荐目录结构
|
||||
|
||||
建议在当前项目中新增以下结构:
|
||||
|
||||
```text
|
||||
src/
|
||||
background/
|
||||
auth/
|
||||
client.ts
|
||||
controller.ts
|
||||
state.ts
|
||||
types.ts
|
||||
index.ts
|
||||
popup/
|
||||
index.html
|
||||
index.ts
|
||||
view.ts
|
||||
types.ts
|
||||
shared/
|
||||
auth-messages.ts
|
||||
auth-config.ts
|
||||
content/
|
||||
market/
|
||||
auth-gate.ts
|
||||
...
|
||||
```
|
||||
|
||||
目录约束:
|
||||
|
||||
- `background/auth/*` 只做认证中枢,不掺杂市场业务逻辑
|
||||
- `popup/*` 只处理展示和用户交互
|
||||
- `shared/*` 放跨入口共享的数据结构和配置解析
|
||||
- `content/market/auth-gate.ts` 只做登录态门控,不直接写登录流程
|
||||
|
||||
## 7. 配置设计
|
||||
|
||||
当前版本只定义占位配置,不写死真实值。
|
||||
|
||||
建议新增 `src/shared/auth-config.ts`,集中管理以下配置:
|
||||
|
||||
```ts
|
||||
export interface AuthConfig {
|
||||
logtoEndpoint: string;
|
||||
appId: string;
|
||||
apiResource: string;
|
||||
scopes: string[];
|
||||
enableDevAuthPanel: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
建议占位字段:
|
||||
|
||||
- `logtoEndpoint`: `https://<your-tenant>.logto.app`
|
||||
- `appId`: `<chrome-extension-app-id>`
|
||||
- `apiResource`: `<your-global-api-resource-indicator>`
|
||||
- `scopes`: `["openid", "profile", "offline_access"]`
|
||||
- `enableDevAuthPanel`: `false`
|
||||
|
||||
配置要求:
|
||||
|
||||
- 缺失配置时必须明确报错
|
||||
- 不允许 silent fail
|
||||
- 后续真实接入时应能通过单点改动切换环境
|
||||
|
||||
## 8. Manifest 变更要求
|
||||
|
||||
现有 manifest 仅覆盖下载和内容脚本注入,接入 Logto 后至少需要补充:
|
||||
|
||||
- `permissions` 增加 `identity`
|
||||
- `permissions` 保留或补充 `storage`
|
||||
- 新增 `action.default_popup`
|
||||
- 根据实际请求域名增加必要的 `host_permissions`
|
||||
|
||||
示意要求:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["downloads", "identity", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实际值需要结合构建输出路径调整,但规范上必须支持 popup 与 identity 登录流程。
|
||||
|
||||
## 9. 认证与会话数据流
|
||||
|
||||
### 9.1 登录流程
|
||||
|
||||
1. 用户点击扩展图标,打开 `popup`
|
||||
2. `popup` 读取 background 返回的 `auth state`
|
||||
3. 如果未登录,点击“登录 Logto”
|
||||
4. `popup` 发送 `auth:sign-in` 给 background
|
||||
5. background 发起 Logto 登录流程
|
||||
6. 登录成功后,background 更新本地认证摘要状态
|
||||
7. `popup` 重新读取状态并渲染已登录信息
|
||||
|
||||
### 9.2 内容脚本门控流程
|
||||
|
||||
1. 内容脚本进入星图市场页
|
||||
2. 插件初始化前先发送 `auth:get-state`
|
||||
3. 如果未登录,渲染禁用态,不执行当前业务能力初始化
|
||||
4. 如果已登录,继续执行现有注入逻辑
|
||||
|
||||
### 9.3 登出流程
|
||||
|
||||
1. 用户在 `popup` 点击退出登录
|
||||
2. `popup` 发送 `auth:sign-out`
|
||||
3. background 清理会话
|
||||
4. `popup` 回到未登录态
|
||||
5. 已打开的市场页在刷新后进入禁用态
|
||||
|
||||
### 9.4 token 策略
|
||||
|
||||
当前版本采用:
|
||||
|
||||
- Authorization Code Flow + PKCE
|
||||
- 基础 scopes:`openid profile offline_access`
|
||||
- `resource` 对应未来业务后端 API 的 global API resource
|
||||
|
||||
约束:
|
||||
|
||||
- `ID token` 仅用于身份展示
|
||||
- `Access token` 仅用于未来业务后端 API 调用
|
||||
- 内容脚本只按需向 background 请求 token,不本地长期存储
|
||||
|
||||
## 10. 消息协议建议
|
||||
|
||||
建议新增一组显式消息类型:
|
||||
|
||||
```ts
|
||||
type AuthMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
```
|
||||
|
||||
建议的状态结构:
|
||||
|
||||
```ts
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
userInfo: {
|
||||
sub: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
scopes: string[];
|
||||
resource: string | null;
|
||||
accessTokenExpiresAt: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
设计要求:
|
||||
|
||||
- 状态必须可序列化
|
||||
- 消息响应必须有稳定结构
|
||||
- 失败时返回明确错误码或错误文案
|
||||
|
||||
## 11. Popup 交互设计
|
||||
|
||||
### 11.1 未登录态
|
||||
|
||||
显示内容:
|
||||
|
||||
- 插件名称
|
||||
- 说明文案:“登录后才能使用星图增强功能”
|
||||
- 主按钮:“登录 Logto”
|
||||
- 最近一次登录失败摘要(如果存在)
|
||||
|
||||
### 11.2 已登录态
|
||||
|
||||
默认展示:
|
||||
|
||||
- 已登录状态
|
||||
- 用户名
|
||||
- 邮箱
|
||||
- `退出登录` 按钮
|
||||
|
||||
### 11.3 开发调试态
|
||||
|
||||
仅在 `enableDevAuthPanel` 开启时显示:
|
||||
|
||||
- `isAuthenticated`
|
||||
- `sub`
|
||||
- `scopes`
|
||||
- `resource`
|
||||
- token 是否可获取
|
||||
- access token 预计过期时间
|
||||
- 最近一次认证错误
|
||||
- 手动“刷新状态”按钮
|
||||
|
||||
默认不显示完整 token 字符串。
|
||||
|
||||
## 12. 内容脚本权限门控设计
|
||||
|
||||
### 12.1 未登录态
|
||||
|
||||
插件工具栏应替换为禁用面板,显示:
|
||||
|
||||
- 主提示:“请先登录插件”
|
||||
- 次提示:“打开扩展弹窗完成登录后刷新本页”
|
||||
- 引导按钮:“去登录”
|
||||
|
||||
“去登录”按钮行为:
|
||||
|
||||
- 不直接尝试从内容脚本打开 popup
|
||||
- 仅提示用户点击浏览器工具栏中的扩展图标完成登录
|
||||
|
||||
未登录时:
|
||||
|
||||
- 不初始化筛选
|
||||
- 不初始化排序
|
||||
- 不初始化导出
|
||||
- 不发起后续业务请求
|
||||
|
||||
### 12.2 已登录态
|
||||
|
||||
保持当前市场增强行为不变。
|
||||
|
||||
### 12.3 登录失效态
|
||||
|
||||
如果内容脚本运行过程中发现 background 返回未登录或 token 不可用:
|
||||
|
||||
- 立即切回禁用态
|
||||
- 提示“登录已失效,请重新登录”
|
||||
|
||||
## 13. 错误处理设计
|
||||
|
||||
至少覆盖以下错误:
|
||||
|
||||
- Logto 配置缺失
|
||||
- 登录取消
|
||||
- 登录回调失败
|
||||
- token 获取失败
|
||||
- popup 无法读取 background 状态
|
||||
- 内容脚本无法获取登录态
|
||||
|
||||
设计要求:
|
||||
|
||||
- popup 中展示用户可理解的错误摘要
|
||||
- background 中保留标准化错误结构
|
||||
- 内容脚本遇到认证错误时优先进入禁用态,而不是继续执行业务逻辑
|
||||
|
||||
## 14. TDD 实施要求
|
||||
|
||||
实现必须采用 TDD,遵循:
|
||||
|
||||
1. 先写失败测试
|
||||
2. 明确验证测试因缺失功能而失败
|
||||
3. 再写最小实现让测试通过
|
||||
4. 最后做必要重构
|
||||
|
||||
不允许先写生产代码再补测试。
|
||||
|
||||
## 15. 测试分层建议
|
||||
|
||||
### 15.1 单元测试:消息协议与状态结构
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/auth-messages.test.ts`
|
||||
- `tests/auth-state-store.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 消息类型收发正确
|
||||
- 状态序列化结构稳定
|
||||
- 配置缺失时能返回明确错误
|
||||
|
||||
### 15.2 单元测试:background 认证协调器
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/background-auth-controller.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- `signIn` 调用认证入口
|
||||
- `signOut` 清理会话
|
||||
- `getAuthState` 返回认证摘要
|
||||
- `getAccessToken` 在未登录或配置无效时失败明确
|
||||
|
||||
### 15.3 单元测试:popup UI
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/popup-entry.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 未登录显示登录按钮
|
||||
- 已登录显示用户信息和退出按钮
|
||||
- 开发模式显示调试区
|
||||
- 错误态显示错误摘要
|
||||
|
||||
### 15.4 集成测试:内容脚本门控
|
||||
|
||||
建议文件:
|
||||
|
||||
- `tests/market-auth-gating.test.ts`
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 未登录时市场页工具栏进入禁用态
|
||||
- 已登录后才初始化现有筛选、排序、导出
|
||||
- 运行中登录失效时切回禁用态
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
- 扩展加载后存在可用的 `popup`
|
||||
- 未登录时 `popup` 显示登录入口
|
||||
- 登录成功后 `popup` 显示基础用户信息
|
||||
- 开发模式下 `popup` 能显示认证调试信息
|
||||
- 未登录时市场页插件整体禁用
|
||||
- 登录后刷新市场页,现有插件能力恢复
|
||||
- 登出后刷新市场页,插件重新禁用
|
||||
- 配置缺失时,popup / background / content 都能给出明确错误,而不是静默失败
|
||||
|
||||
## 17. 后续实现顺序建议
|
||||
|
||||
建议实施顺序:
|
||||
|
||||
1. 补 manifest 与构建入口,先让 popup 可以独立加载
|
||||
2. 写 `auth-messages` 与 `auth-config` 的失败测试
|
||||
3. 写 background auth controller 的失败测试并落最小实现
|
||||
4. 写 popup 状态渲染测试并接上 background
|
||||
5. 写内容脚本门控测试,再把市场页逻辑接入认证态
|
||||
6. 最后补开发调试面板
|
||||
|
||||
## 18. 当前仍保留为占位的实施参数
|
||||
|
||||
这些值在真正开始编码前仍需由你提供:
|
||||
|
||||
- Logto tenant endpoint
|
||||
- Logto Chrome extension appId
|
||||
- Global API resource 标识
|
||||
- 是否存在额外自定义 scopes
|
||||
- 调试面板是否区分开发包与生产包
|
||||
|
||||
在这些真实值到位前,可以先完成目录搭建、消息协议、状态门控和测试骨架。
|
||||
1094
package-lock.json
generated
1094
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -5,23 +5,11 @@
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"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.
@ -1,5 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveExtensionBuildDir(projectRoot, _buildTarget) {
|
||||
return path.join(projectRoot, "dist");
|
||||
}
|
||||
@ -1,20 +1,16 @@
|
||||
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { cp, mkdir, rm } 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 buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
|
||||
const distDir = resolveExtensionBuildDir(projectRoot, buildTarget);
|
||||
const distDir = path.join(projectRoot, "dist");
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
await mkdir(path.join(distDir, "content"), { recursive: true });
|
||||
await mkdir(path.join(distDir, "background"), { recursive: true });
|
||||
await mkdir(path.join(distDir, "popup"), { recursive: true });
|
||||
|
||||
await build({
|
||||
entry: {
|
||||
@ -54,33 +50,7 @@ await build({
|
||||
}
|
||||
});
|
||||
|
||||
await build({
|
||||
entry: {
|
||||
index: path.join(projectRoot, "src/popup/index.ts")
|
||||
},
|
||||
format: ["iife"],
|
||||
platform: "browser",
|
||||
target: "chrome114",
|
||||
outDir: path.join(distDir, "popup"),
|
||||
clean: false,
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
outExtension() {
|
||||
return { js: ".js" };
|
||||
}
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
path.join(distDir, "manifest.json"),
|
||||
`${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n`
|
||||
);
|
||||
await cp(
|
||||
path.join(projectRoot, "src/popup/index.html"),
|
||||
path.join(distDir, "popup/index.html")
|
||||
);
|
||||
await cp(
|
||||
path.join(projectRoot, "src/assets"),
|
||||
path.join(distDir, "assets"),
|
||||
{ recursive: true }
|
||||
path.join(projectRoot, "src/manifest.json"),
|
||||
path.join(distDir, "manifest.json")
|
||||
);
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
import COS from "cos-nodejs-sdk-v5";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { buildReleaseUploadTargets } from "../release-assets.mjs";
|
||||
|
||||
export async function uploadReleaseAssets(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const projectRoot = options.projectRoot ?? resolveProjectRoot();
|
||||
const releaseVersion = options.releaseVersion ?? env.EXTENSION_VERSION ?? env.DRONE_TAG;
|
||||
|
||||
if (!releaseVersion) {
|
||||
throw new Error("release version is required for COS upload");
|
||||
}
|
||||
|
||||
const cos = options.cosClient ?? createCosClient(env);
|
||||
const targets =
|
||||
options.targets ??
|
||||
buildReleaseUploadTargets({
|
||||
projectRoot,
|
||||
releaseVersion
|
||||
});
|
||||
|
||||
for (const target of targets) {
|
||||
const body = await readFile(target.localPath);
|
||||
await putObjectAsync(cos, {
|
||||
Bucket: getRequiredEnv(env, "COS_BUCKET"),
|
||||
Body: body,
|
||||
ContentType: getContentType(target.cosKey),
|
||||
Key: target.cosKey,
|
||||
Region: getRequiredEnv(env, "COS_REGION")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function putObjectAsync(client, params) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
client.putObject(params, (error, data) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createCosClient(env) {
|
||||
return new COS({
|
||||
SecretId: getRequiredEnv(env, "COS_SECRET_ID"),
|
||||
SecretKey: getRequiredEnv(env, "COS_SECRET_KEY")
|
||||
});
|
||||
}
|
||||
|
||||
function getContentType(key) {
|
||||
if (key.endsWith(".json")) {
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (key.endsWith(".pdf")) {
|
||||
return "application/pdf";
|
||||
}
|
||||
|
||||
if (key.endsWith(".zip")) {
|
||||
return "application/zip";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function getRequiredEnv(env, name) {
|
||||
const value = env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveProjectRoot() {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
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://localhost: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
|
||||
};
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import http from "node:http";
|
||||
|
||||
export function createMockProtectedApiServer({ port = 4319 } = {}) {
|
||||
let server;
|
||||
|
||||
return {
|
||||
get baseUrl() {
|
||||
const address = server?.address();
|
||||
const resolvedPort =
|
||||
typeof address === "object" && address ? address.port : port;
|
||||
|
||||
return `http://127.0.0.1:${resolvedPort}`;
|
||||
},
|
||||
async start() {
|
||||
server = http.createServer(async (request, response) => {
|
||||
if (request.url === "/api/mock/protected") {
|
||||
const authHeader = readBearerToken(request, response);
|
||||
if (!authHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-protected-api",
|
||||
message: "authorized",
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url === "/api/mock/batches" && request.method === "POST") {
|
||||
const authHeader = readBearerToken(request, response);
|
||||
if (!authHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJsonBody(request);
|
||||
const authors = Array.isArray(payload?.authors) ? payload.authors : [];
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: "mock-batch-submit",
|
||||
acceptedCount: authors.length,
|
||||
batchId:
|
||||
typeof payload?.batchId === "string" ? payload.batchId : null,
|
||||
receivedAuthHeader: authHeader
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "not-found" }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
server.listen(port, "127.0.0.1", resolve);
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readBearerToken(request, response) {
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const isBearer =
|
||||
typeof authHeader === "string" &&
|
||||
authHeader.startsWith("Bearer ") &&
|
||||
authHeader.length > "Bearer ".length;
|
||||
|
||||
if (!isBearer) {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
return null;
|
||||
}
|
||||
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const chunks = [];
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const server = createMockProtectedApiServer();
|
||||
await server.start();
|
||||
console.log(`mock protected api listening on ${server.baseUrl}`);
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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}`);
|
||||
@ -1,25 +0,0 @@
|
||||
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")
|
||||
}
|
||||
];
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
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`
|
||||
};
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 777 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="12" x2="114" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9F1239"/>
|
||||
<stop offset="1" stop-color="#4C0519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
|
||||
<path d="M34 80L50 62L64 70L83 46" stroke="#FFF7ED" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
||||
<circle cx="86" cy="44" r="8" fill="#FFF7ED"/>
|
||||
<path d="M79 80C79 71.7157 85.7157 65 94 65C102.284 65 109 71.7157 109 80C109 88.2843 102.284 95 94 95C85.7157 95 79 88.2843 79 80Z" stroke="#FFF7ED" stroke-width="8"/>
|
||||
<path d="M104 91L114 101" stroke="#FFF7ED" stroke-linecap="round" stroke-width="8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 822 B |
@ -1,54 +0,0 @@
|
||||
import LogtoClient from "@logto/chrome-extension";
|
||||
|
||||
import { readAuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthClientLike } from "./types";
|
||||
|
||||
export function createLogtoAuthClient(): AuthClientLike {
|
||||
const config = readAuthConfig();
|
||||
const client = new LogtoClient({
|
||||
appId: config.appId,
|
||||
endpoint: config.logtoEndpoint,
|
||||
resources: [config.apiResource],
|
||||
scopes: config.scopes
|
||||
});
|
||||
|
||||
return {
|
||||
getAccessToken(resource?: string) {
|
||||
return client.getAccessToken(resource);
|
||||
},
|
||||
getIdTokenClaims() {
|
||||
return client.getIdTokenClaims();
|
||||
},
|
||||
isAuthenticated() {
|
||||
return client.isAuthenticated();
|
||||
},
|
||||
signIn() {
|
||||
return client.signIn(readChromeIdentity().getRedirectURL("/callback"));
|
||||
},
|
||||
signOut() {
|
||||
return client.signOut(readChromeIdentity().getRedirectURL());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readChromeIdentity(): {
|
||||
getRedirectURL: (path?: string) => string;
|
||||
} {
|
||||
const identity = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
identity?: {
|
||||
getRedirectURL?: (path?: string) => string;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.identity;
|
||||
|
||||
if (typeof identity?.getRedirectURL !== "function") {
|
||||
throw new Error("chrome.identity.getRedirectURL is unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
getRedirectURL: identity.getRedirectURL.bind(identity)
|
||||
};
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { readAuthConfig, type AuthConfig } from "../../shared/auth-config";
|
||||
import { createLoggedInAuthState, createLoggedOutAuthState } from "./state";
|
||||
import type { AuthClientLike } from "./types";
|
||||
|
||||
export interface AuthController {
|
||||
getAccessToken(): Promise<string>;
|
||||
getAuthState(): Promise<ReturnType<typeof createLoggedOutAuthState>>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAuthController(options: {
|
||||
authClient: AuthClientLike;
|
||||
config?: AuthConfig;
|
||||
}): AuthController {
|
||||
const config = options.config ?? readAuthConfig();
|
||||
|
||||
return {
|
||||
async getAccessToken() {
|
||||
return options.authClient.getAccessToken(config.apiResource);
|
||||
},
|
||||
async getAuthState() {
|
||||
const isAuthenticated = await options.authClient.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return createLoggedOutAuthState(config);
|
||||
}
|
||||
|
||||
try {
|
||||
await options.authClient.getAccessToken(config.apiResource);
|
||||
} catch (error) {
|
||||
return createLoggedOutAuthState(
|
||||
config,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const claims = await options.authClient.getIdTokenClaims();
|
||||
return createLoggedInAuthState(claims, config);
|
||||
},
|
||||
async signIn() {
|
||||
await options.authClient.signIn();
|
||||
},
|
||||
async signOut() {
|
||||
await options.authClient.signOut();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import type { AuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
|
||||
export function createLoggedOutAuthState(
|
||||
config?: Pick<AuthConfig, "apiResource">,
|
||||
lastError?: string | null
|
||||
): AuthStateValue {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
lastError: lastError ?? null,
|
||||
resource: config?.apiResource ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function createLoggedInAuthState(
|
||||
claims: Record<string, unknown> | null | undefined,
|
||||
config?: Pick<AuthConfig, "apiResource" | "scopes">
|
||||
): AuthStateValue {
|
||||
return {
|
||||
accessTokenExpiresAt: null,
|
||||
isAuthenticated: true,
|
||||
resource: config?.apiResource ?? null,
|
||||
scopes: config?.scopes ?? [],
|
||||
tokenAvailable: true,
|
||||
userInfo: {
|
||||
email: readStringClaim(claims, "email"),
|
||||
name: readStringClaim(claims, "name"),
|
||||
sub: readStringClaim(claims, "sub"),
|
||||
username: readStringClaim(claims, "username")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readStringClaim(
|
||||
claims: Record<string, unknown> | null | undefined,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const value = claims?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export interface AuthClientLike {
|
||||
getAccessToken(resource?: string): Promise<string>;
|
||||
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
|
||||
isAuthenticated(): Promise<boolean>;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
@ -1,15 +1,3 @@
|
||||
import { createAuthController, type AuthController } from "./auth/controller";
|
||||
import { createLogtoAuthClient } from "./auth/client";
|
||||
import {
|
||||
isAuthRequestMessage,
|
||||
type AuthResponseMessage
|
||||
} from "../shared/auth-messages";
|
||||
import { createBatchSubmitClient } from "../shared/batch-submit-client";
|
||||
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
|
||||
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../shared/batch-submit-config";
|
||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
|
||||
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
|
||||
|
||||
interface ChromeDownloadsLike {
|
||||
download(
|
||||
options: {
|
||||
@ -44,31 +32,18 @@ type DownloadMarketCsvMessage = {
|
||||
type: "download-market-csv";
|
||||
};
|
||||
|
||||
type BatchSubmitMessage = {
|
||||
payload: unknown;
|
||||
type: "batch:submit";
|
||||
};
|
||||
|
||||
type DownloadUpdateMessage = {
|
||||
filename: string;
|
||||
type: "update:download";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function registerBackgroundMessageHandler(
|
||||
chromeLike: ChromeLike = readChromeLike(),
|
||||
dependencies: {
|
||||
authController?: AuthController;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<unknown>;
|
||||
submitBatch?: (payload: unknown) => Promise<unknown>;
|
||||
} = {}
|
||||
chromeLike: ChromeLike = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: ChromeLike;
|
||||
}
|
||||
).chrome ?? {}
|
||||
): void {
|
||||
let authController = dependencies.authController;
|
||||
let searchBackendMetrics = dependencies.searchBackendMetrics;
|
||||
let submitBatch = dependencies.submitBatch;
|
||||
|
||||
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
|
||||
if (isDownloadMarketCsvMessage(message)) {
|
||||
if (!isDownloadMarketCsvMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void triggerCsvDownload(chromeLike, message)
|
||||
.then(() => {
|
||||
sendResponse({ ok: true });
|
||||
@ -81,168 +56,7 @@ 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()
|
||||
});
|
||||
submitBatch ??= createBatchSubmitClient({
|
||||
baseUrl: DEFAULT_BATCH_SUBMIT_BASE_URL,
|
||||
getAccessToken: () => authController!.getAccessToken(),
|
||||
sendMessage: () =>
|
||||
Promise.reject(new Error("background batch submit does not use sendMessage"))
|
||||
}).submitBatch;
|
||||
|
||||
void submitBatch(message.payload)
|
||||
.then((value) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
type: "batch:ack",
|
||||
value
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "batch:error"
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBackendMetricsSearchRequestMessage(message)) {
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
});
|
||||
searchBackendMetrics ??= createBackendMetricsClient({
|
||||
baseUrl: DEFAULT_BACKEND_METRICS_BASE_URL,
|
||||
getAccessToken: () => authController!.getAccessToken()
|
||||
}).searchByStarIds;
|
||||
|
||||
void searchBackendMetrics(message.value.starIds)
|
||||
.then((rows) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
type: "backend-metrics:result",
|
||||
value: {
|
||||
rows
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "backend-metrics:error"
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAuthRequestMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
authController ??= createAuthController({
|
||||
authClient: createLogtoAuthClient()
|
||||
});
|
||||
|
||||
void handleAuthMessage(authController, message)
|
||||
.then((response) => {
|
||||
sendResponse(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
type: "auth:error"
|
||||
} satisfies AuthResponseMessage);
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerUpdateDownload(
|
||||
chromeLike: ChromeLike,
|
||||
message: DownloadUpdateMessage
|
||||
): Promise<void> {
|
||||
if (!chromeLike.downloads?.download) {
|
||||
throw new Error("chrome.downloads.download is unavailable");
|
||||
}
|
||||
|
||||
await Promise.resolve(
|
||||
chromeLike.downloads.download({
|
||||
filename: message.filename,
|
||||
saveAs: true,
|
||||
url: message.url
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAuthMessage(
|
||||
authController: AuthController,
|
||||
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
||||
): Promise<AuthResponseMessage> {
|
||||
if (message.type === "auth:get-state") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: await authController.getAuthState()
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "auth:get-access-token") {
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:token",
|
||||
value: {
|
||||
accessToken: await authController.getAccessToken()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "auth:sign-in") {
|
||||
await authController.signIn();
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:ack"
|
||||
};
|
||||
}
|
||||
|
||||
await authController.signOut();
|
||||
return {
|
||||
ok: true,
|
||||
type: "auth:ack"
|
||||
};
|
||||
}
|
||||
|
||||
function readChromeLike(): ChromeLike {
|
||||
return (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: ChromeLike;
|
||||
}
|
||||
).chrome ?? {};
|
||||
}
|
||||
|
||||
async function triggerCsvDownload(
|
||||
@ -278,29 +92,4 @@ 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;
|
||||
}
|
||||
|
||||
const candidate = message as Partial<BatchSubmitMessage>;
|
||||
return candidate.type === "batch:submit" && "payload" in candidate;
|
||||
}
|
||||
|
||||
registerBackgroundMessageHandler();
|
||||
|
||||
@ -2,11 +2,6 @@ import {
|
||||
createMarketController,
|
||||
type CreateMarketControllerOptions
|
||||
} from "./market/index";
|
||||
import { renderMarketAuthGate } from "./market/auth-gate";
|
||||
import {
|
||||
isAuthResponseMessage,
|
||||
type AuthStateValue
|
||||
} from "../shared/auth-messages";
|
||||
|
||||
interface ChromeRuntimeLike {
|
||||
getURL?: (path: string) => string;
|
||||
@ -21,7 +16,6 @@ interface BootContentScriptOptions {
|
||||
options: CreateMarketControllerOptions
|
||||
) => { dispose?: () => void; ready: Promise<void> };
|
||||
document?: Document;
|
||||
sendAuthMessage?: (message: unknown) => Promise<unknown>;
|
||||
window?: Window;
|
||||
}
|
||||
|
||||
@ -32,8 +26,6 @@ export async function bootContentScript(
|
||||
const currentDocument = options.document ?? document;
|
||||
const controllerFactory =
|
||||
options.createMarketController ?? createMarketController;
|
||||
const sendAuthMessage =
|
||||
options.sendAuthMessage ?? createRuntimeMessageSender();
|
||||
|
||||
if (!isMarketPage(currentWindow.location.href)) {
|
||||
return null;
|
||||
@ -41,50 +33,19 @@ export async function bootContentScript(
|
||||
|
||||
installMarketPageBridge(currentDocument);
|
||||
|
||||
const authState = await readAuthState(sendAuthMessage);
|
||||
if (!authState?.isAuthenticated) {
|
||||
await waitForBodyReady(currentDocument, currentWindow);
|
||||
renderMarketAuthGate(
|
||||
currentDocument,
|
||||
currentWindow,
|
||||
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
|
||||
);
|
||||
return {
|
||||
ready: Promise.resolve()
|
||||
};
|
||||
}
|
||||
|
||||
await waitForBodyReady(currentDocument, currentWindow);
|
||||
|
||||
return controllerFactory({
|
||||
document: currentDocument,
|
||||
onCsvReady: (csv: string, filename?: string) => {
|
||||
if (filename) {
|
||||
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||
return;
|
||||
}
|
||||
|
||||
onCsvReady: (csv: string) => {
|
||||
if (requestCsvDownload(csv)) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||
downloadCsv(currentDocument, currentWindow, csv);
|
||||
},
|
||||
window: currentWindow
|
||||
});
|
||||
}
|
||||
|
||||
async function readAuthState(
|
||||
sendMessage: (message: unknown) => Promise<unknown>
|
||||
): Promise<AuthStateValue | null> {
|
||||
const response = await sendMessage({ type: "auth:get-state" });
|
||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.value;
|
||||
}
|
||||
|
||||
function isMarketPage(url: string): boolean {
|
||||
const parsedUrl = new URL(url);
|
||||
const isXingtuHost =
|
||||
@ -121,7 +82,7 @@ function bootstrapContentScript() {
|
||||
|
||||
bootstrapContentScript();
|
||||
|
||||
function requestCsvDownload(csv: string, filename?: string): boolean {
|
||||
function requestCsvDownload(csv: string): boolean {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: { runtime?: ChromeRuntimeLike };
|
||||
@ -134,59 +95,20 @@ function requestCsvDownload(csv: string, filename?: string): boolean {
|
||||
|
||||
runtime.sendMessage({
|
||||
csv,
|
||||
filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
|
||||
return async (message: unknown) => {
|
||||
const runtime = (
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: { runtime?: ChromeRuntimeLike };
|
||||
}
|
||||
).chrome?.runtime;
|
||||
|
||||
if (typeof runtime?.sendMessage !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runtime.sendMessage(message);
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForBodyReady(document: Document, currentWindow: Window): Promise<void> {
|
||||
if (document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handleReady = () => {
|
||||
if (document.body) {
|
||||
document.removeEventListener("DOMContentLoaded", handleReady);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", handleReady);
|
||||
currentWindow.setTimeout(handleReady, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadCsv(
|
||||
document: Document,
|
||||
window: Window,
|
||||
csv: string,
|
||||
filename?: string
|
||||
): void {
|
||||
function downloadCsv(document: Document, window: Window, csv: string): void {
|
||||
const blob = new Blob(["\uFEFF", csv], {
|
||||
type: "text/csv;charset=utf-8"
|
||||
});
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@ -197,14 +119,6 @@ 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(
|
||||
|
||||
@ -109,7 +109,7 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
|
||||
data.personal_avg_search_after_view_rate
|
||||
);
|
||||
|
||||
if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) {
|
||||
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "missing-rate"
|
||||
@ -119,8 +119,8 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
|
||||
return {
|
||||
success: true,
|
||||
rates: {
|
||||
...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}),
|
||||
...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {})
|
||||
singleVideoAfterSearchRate,
|
||||
personalVideoAfterSearchRate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,303 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,263 +0,0 @@
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import {
|
||||
buildMarketCsvColumns,
|
||||
listBackendMetricCsvHeaders,
|
||||
listRateCsvHeaders,
|
||||
type CsvColumn
|
||||
} from "./csv-exporter";
|
||||
import type {
|
||||
AudienceProfileDistributionItem,
|
||||
AudienceProfileExportRow,
|
||||
AudienceProfileKind,
|
||||
AudienceProfileResult,
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics
|
||||
} from "./audience-profile-types";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
|
||||
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_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: buildSpreadInfoColumns(),
|
||||
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 buildBusinessEstimateColumns();
|
||||
}
|
||||
|
||||
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 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] ?? [] : [];
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
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 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";
|
||||
}
|
||||
|
||||
export interface BusinessAbilityFailure {
|
||||
failureReason?: string;
|
||||
status: "failed";
|
||||
}
|
||||
|
||||
export type BusinessAbilityResult =
|
||||
| BusinessAbilitySuccess
|
||||
| BusinessAbilityFailure;
|
||||
@ -1,34 +0,0 @@
|
||||
export function renderMarketAuthGate(
|
||||
document: Document,
|
||||
currentWindow: Window,
|
||||
message = "请先登录插件"
|
||||
): HTMLElement {
|
||||
const existingGate = document.querySelector(
|
||||
'[data-market-auth-gate="root"]'
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (existingGate) {
|
||||
return existingGate;
|
||||
}
|
||||
|
||||
const root = document.createElement("section");
|
||||
root.dataset.marketAuthGate = "root";
|
||||
root.innerHTML = `
|
||||
<strong></strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
const title = root.querySelector("strong");
|
||||
if (title) {
|
||||
title.textContent = message;
|
||||
}
|
||||
|
||||
root
|
||||
.querySelector('[data-market-auth-help="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
currentWindow.alert("请点击浏览器工具栏中的扩展图标完成登录");
|
||||
});
|
||||
|
||||
document.body.prepend(root);
|
||||
return root;
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
const DIALOG_STYLE_ID = "sces-batch-name-dialog-style";
|
||||
|
||||
const activeDialogs = new WeakMap<
|
||||
Document,
|
||||
{
|
||||
input: HTMLInputElement;
|
||||
promise: Promise<string | null>;
|
||||
}
|
||||
>();
|
||||
|
||||
export function promptForBatchName(document: Document): Promise<string | null> {
|
||||
const existingDialog = activeDialogs.get(document);
|
||||
if (existingDialog) {
|
||||
existingDialog.input.focus();
|
||||
existingDialog.input.select();
|
||||
return existingDialog.promise;
|
||||
}
|
||||
|
||||
ensureDialogStyles(document);
|
||||
|
||||
const dialogRoot = document.createElement("div");
|
||||
dialogRoot.dataset.pluginBatchNameDialog = "root";
|
||||
dialogRoot.setAttribute("role", "dialog");
|
||||
dialogRoot.setAttribute("aria-modal", "true");
|
||||
dialogRoot.setAttribute("aria-labelledby", "sces-batch-name-title");
|
||||
applyOverlayStyles(dialogRoot);
|
||||
|
||||
const dialogPanel = document.createElement("div");
|
||||
applyPanelStyles(dialogPanel);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.id = "sces-batch-name-title";
|
||||
title.textContent = "提交批次";
|
||||
applyTitleStyles(title);
|
||||
|
||||
const description = document.createElement("p");
|
||||
description.textContent = "请输入批次名称,便于后续在系统中识别和追踪。";
|
||||
applyDescriptionStyles(description);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.dataset.pluginBatchNameInput = "input";
|
||||
input.placeholder = "例如:618达人筛选第一批";
|
||||
input.maxLength = 60;
|
||||
applyInputStyles(input);
|
||||
|
||||
const errorText = document.createElement("p");
|
||||
errorText.dataset.pluginBatchNameError = "text";
|
||||
applyErrorStyles(errorText);
|
||||
|
||||
const buttonRow = document.createElement("div");
|
||||
applyButtonRowStyles(buttonRow);
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.dataset.pluginBatchNameCancel = "button";
|
||||
cancelButton.textContent = "取消";
|
||||
applySecondaryButtonStyles(cancelButton);
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.type = "button";
|
||||
confirmButton.dataset.pluginBatchNameConfirm = "button";
|
||||
confirmButton.textContent = "确认提交";
|
||||
applyPrimaryButtonStyles(confirmButton);
|
||||
|
||||
buttonRow.append(cancelButton, confirmButton);
|
||||
dialogPanel.append(title, description, input, errorText, buttonRow);
|
||||
dialogRoot.appendChild(dialogPanel);
|
||||
document.body.appendChild(dialogRoot);
|
||||
|
||||
const dialogPromise = new Promise<string | null>((resolve) => {
|
||||
const closeDialog = (value: string | null) => {
|
||||
activeDialogs.delete(document);
|
||||
dialogRoot.remove();
|
||||
document.removeEventListener("keydown", handleDocumentKeydown, true);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const submitValue = () => {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
errorText.textContent = "请输入批次名称";
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(value);
|
||||
};
|
||||
|
||||
const handleDocumentKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitValue();
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
if (!input.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorText.textContent = "";
|
||||
input.removeAttribute("aria-invalid");
|
||||
});
|
||||
cancelButton.addEventListener("click", () => {
|
||||
closeDialog(null);
|
||||
});
|
||||
confirmButton.addEventListener("click", () => {
|
||||
submitValue();
|
||||
});
|
||||
dialogRoot.addEventListener("click", (event) => {
|
||||
if (event.target === dialogRoot) {
|
||||
closeDialog(null);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", handleDocumentKeydown, true);
|
||||
});
|
||||
|
||||
activeDialogs.set(document, {
|
||||
input,
|
||||
promise: dialogPromise
|
||||
});
|
||||
|
||||
input.focus();
|
||||
|
||||
return dialogPromise;
|
||||
}
|
||||
|
||||
function ensureDialogStyles(document: Document): void {
|
||||
if (document.getElementById(DIALOG_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = DIALOG_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-batch-name-dialog="root"] {
|
||||
animation: sces-batch-name-fade-in 0.16s ease;
|
||||
}
|
||||
|
||||
@keyframes sces-batch-name-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function applyOverlayStyles(root: HTMLElement): void {
|
||||
root.style.position = "fixed";
|
||||
root.style.inset = "0";
|
||||
root.style.background = "rgba(15, 23, 42, 0.38)";
|
||||
root.style.display = "flex";
|
||||
root.style.alignItems = "center";
|
||||
root.style.justifyContent = "center";
|
||||
root.style.padding = "24px";
|
||||
root.style.zIndex = "2147483647";
|
||||
}
|
||||
|
||||
function applyPanelStyles(panel: HTMLElement): void {
|
||||
panel.style.width = "min(420px, calc(100vw - 32px))";
|
||||
panel.style.background = "#fffaf9";
|
||||
panel.style.border = "1px solid rgba(127, 29, 45, 0.14)";
|
||||
panel.style.borderRadius = "18px";
|
||||
panel.style.boxShadow = "0 28px 70px rgba(15, 23, 42, 0.22)";
|
||||
panel.style.padding = "24px";
|
||||
panel.style.boxSizing = "border-box";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0";
|
||||
title.style.color = "#4c0519";
|
||||
title.style.fontSize = "20px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "28px";
|
||||
}
|
||||
|
||||
function applyDescriptionStyles(description: HTMLElement): void {
|
||||
description.style.margin = "10px 0 0";
|
||||
description.style.color = "#64748b";
|
||||
description.style.fontSize = "13px";
|
||||
description.style.lineHeight = "20px";
|
||||
}
|
||||
|
||||
function applyInputStyles(input: HTMLInputElement): void {
|
||||
input.style.width = "100%";
|
||||
input.style.height = "42px";
|
||||
input.style.marginTop = "18px";
|
||||
input.style.padding = "0 14px";
|
||||
input.style.boxSizing = "border-box";
|
||||
input.style.border = "1px solid #d8c1c6";
|
||||
input.style.borderRadius = "12px";
|
||||
input.style.background = "#ffffff";
|
||||
input.style.color = "#1f2937";
|
||||
input.style.fontSize = "14px";
|
||||
input.style.outline = "none";
|
||||
}
|
||||
|
||||
function applyErrorStyles(errorText: HTMLElement): void {
|
||||
errorText.style.minHeight = "20px";
|
||||
errorText.style.margin = "8px 0 0";
|
||||
errorText.style.color = "#b91c1c";
|
||||
errorText.style.fontSize = "12px";
|
||||
errorText.style.lineHeight = "18px";
|
||||
}
|
||||
|
||||
function applyButtonRowStyles(buttonRow: HTMLElement): void {
|
||||
buttonRow.style.display = "flex";
|
||||
buttonRow.style.justifyContent = "flex-end";
|
||||
buttonRow.style.gap = "10px";
|
||||
buttonRow.style.marginTop = "18px";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "36px";
|
||||
button.style.padding = "0 16px";
|
||||
button.style.border = "1px solid #d7dde6";
|
||||
button.style.borderRadius = "10px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#334155";
|
||||
button.style.fontWeight = "600";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "36px";
|
||||
button.style.padding = "0 16px";
|
||||
button.style.border = "1px solid #7f1d2d";
|
||||
button.style.borderRadius = "10px";
|
||||
button.style.background = "#7f1d2d";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.fontWeight = "600";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export interface BatchPayload {
|
||||
authors: Array<{
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorUid?: string;
|
||||
}>;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
creatorName: string;
|
||||
logtoUserId: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
export function createBatchPayload(options: {
|
||||
authState: AuthStateValue;
|
||||
batchName: string;
|
||||
createdAt: string;
|
||||
records: MarketRecord[];
|
||||
}): BatchPayload {
|
||||
const logtoUserId = options.authState.userInfo?.sub?.trim();
|
||||
if (!logtoUserId) {
|
||||
throw new Error("batch submit user id unavailable");
|
||||
}
|
||||
|
||||
const resource = options.authState.resource?.trim();
|
||||
if (!resource) {
|
||||
throw new Error("batch submit resource unavailable");
|
||||
}
|
||||
|
||||
const batchName = options.batchName.trim();
|
||||
if (!batchName) {
|
||||
throw new Error("batch submit batch name is required");
|
||||
}
|
||||
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
authorName: record.authorName,
|
||||
...(record.coreUserId ? { authorUid: record.coreUserId } : {})
|
||||
})),
|
||||
batchName,
|
||||
createdAt: options.createdAt,
|
||||
creatorName:
|
||||
options.authState.userInfo?.name ??
|
||||
options.authState.userInfo?.username ??
|
||||
logtoUserId,
|
||||
logtoUserId,
|
||||
resource
|
||||
};
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
import type { MarketRecord } from "./types";
|
||||
import type {
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics,
|
||||
BusinessAbilityResult,
|
||||
BusinessAbilitySuccess
|
||||
} 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;
|
||||
}
|
||||
|
||||
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 estimates = await loadJson(
|
||||
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
||||
);
|
||||
|
||||
if (!estimates.ok) {
|
||||
return {
|
||||
failureReason: estimates.failureReason,
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
||||
status: "success"
|
||||
} 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 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 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 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 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;
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export type CsvColumn = {
|
||||
type CsvColumn = {
|
||||
header: string;
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
@ -29,7 +28,7 @@ const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
|
||||
const RATE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "商单视频看后搜率",
|
||||
header: "单视频看后搜率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.rates?.singleVideoAfterSearchRate
|
||||
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
|
||||
@ -44,52 +43,9 @@ const RATE_COLUMNS: CsvColumn[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "秒思api-看后搜率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.afterViewSearchRate ?? ""
|
||||
},
|
||||
{
|
||||
header: "秒思api-看后搜数",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.afterViewSearchCount ?? ""
|
||||
},
|
||||
{
|
||||
header: "秒思api-新增A3数",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.a3IncreaseCount ?? ""
|
||||
},
|
||||
{
|
||||
header: "秒思api-新增A3率",
|
||||
readValue: (record: MarketRecord) =>
|
||||
record.backendMetrics?.newA3Rate ?? ""
|
||||
},
|
||||
{
|
||||
header: "秒思api-CPA3",
|
||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
|
||||
},
|
||||
{
|
||||
header: "秒思api-cp_search",
|
||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
|
||||
}
|
||||
];
|
||||
|
||||
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
|
||||
header,
|
||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||
}));
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function listBackendMetricCsvHeaders(): string[] {
|
||||
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
const csvColumns = buildMarketCsvColumns(records);
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
|
||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||
const rowLines = records.map((record) =>
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
@ -98,24 +54,13 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
return [headerLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
return [
|
||||
...baseColumns,
|
||||
...RATE_COLUMNS,
|
||||
...BACKEND_METRIC_COLUMNS,
|
||||
...SPREAD_INFO_COLUMNS
|
||||
];
|
||||
}
|
||||
|
||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const orderedHeaders: string[] = [];
|
||||
const seenHeaders = new Set<string>();
|
||||
const excludedHeaders = new Set(["代表视频"]);
|
||||
|
||||
records.forEach((record) => {
|
||||
Object.keys(record.exportFields ?? {}).forEach((header) => {
|
||||
if (seenHeaders.has(header) || excludedHeaders.has(header)) {
|
||||
if (seenHeaders.has(header)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,6 @@ interface ExportRangeControllerOptions {
|
||||
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||
prepareCurrentPageForExport(): Promise<void>;
|
||||
readCurrentPageRecords(): MarketRecord[];
|
||||
readCurrentPageRowCount(): number;
|
||||
window: Window;
|
||||
}
|
||||
|
||||
@ -27,10 +26,13 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
||||
currentPage,
|
||||
totalPages: target.mode === "count" ? target.pageCount : undefined
|
||||
});
|
||||
const currentPageRecords = await preparePageRecords(expectedMinimumRowCount);
|
||||
if (!currentPageRecords) {
|
||||
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
|
||||
if (!currentPageReady) {
|
||||
throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`);
|
||||
}
|
||||
|
||||
await options.prepareCurrentPageForExport();
|
||||
const currentPageRecords = options.readCurrentPageRecords();
|
||||
currentPageRecords.forEach((record) => {
|
||||
const existingRecord = mergedRecords.get(record.authorId);
|
||||
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
||||
@ -61,33 +63,6 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
||||
}
|
||||
};
|
||||
|
||||
async function preparePageRecords(
|
||||
expectedMinimumRowCount: number | undefined
|
||||
): Promise<MarketRecord[] | null> {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const currentPageReady = await waitForCurrentPageReady();
|
||||
if (!currentPageReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await options.prepareCurrentPageForExport();
|
||||
const currentPageRecords = options.readCurrentPageRecords();
|
||||
if (
|
||||
currentPageRecords.length > 0 &&
|
||||
(
|
||||
typeof expectedMinimumRowCount !== "number" ||
|
||||
expectedMinimumRowCount <= 0 ||
|
||||
isCurrentPageTerminal() ||
|
||||
currentPageRecords.length >= expectedMinimumRowCount
|
||||
)
|
||||
) {
|
||||
return currentPageRecords;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForPageChange(previousSignature: string): Promise<boolean> {
|
||||
const previousPageState = parsePageSignature(previousSignature);
|
||||
|
||||
@ -107,7 +82,9 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForCurrentPageReady(): Promise<boolean> {
|
||||
async function waitForCurrentPageReady(
|
||||
expectedMinimumRowCount: number | undefined
|
||||
): Promise<boolean> {
|
||||
let stableAttemptCount = 0;
|
||||
let lastReadyFingerprint = "";
|
||||
|
||||
@ -124,6 +101,17 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof expectedMinimumRowCount === "number" &&
|
||||
expectedMinimumRowCount > 0 &&
|
||||
!pageState.isTerminalPage &&
|
||||
pageState.rowCount < expectedMinimumRowCount
|
||||
) {
|
||||
stableAttemptCount = 0;
|
||||
lastReadyFingerprint = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
const readyFingerprint = [
|
||||
pageState.pageToken,
|
||||
pageState.authorIds,
|
||||
@ -158,13 +146,9 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
||||
authorIds: pageSignature.authorIds,
|
||||
isTerminalPage: isPageControlDisabled(nextPageControl),
|
||||
pageToken: pageSignature.pageToken,
|
||||
rowCount: options.readCurrentPageRowCount()
|
||||
rowCount: options.readCurrentPageRecords().length
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentPageTerminal(): boolean {
|
||||
return isPageControlDisabled(findNextPageControl(options.document));
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSignature(signature: string): {
|
||||
@ -222,7 +206,6 @@ function mergeMarketRecord(
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
incomingRecord.exportFields
|
||||
|
||||
@ -3,9 +3,6 @@ import {
|
||||
parseRateLowerBound
|
||||
} from "../../shared/rate-normalizer";
|
||||
import type {
|
||||
AfterSearchRates,
|
||||
BackendMetrics,
|
||||
MarketSortField,
|
||||
MarketFilterState,
|
||||
MarketRecord,
|
||||
MarketSortState
|
||||
@ -70,26 +67,13 @@ function compareRecords(
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
if (isRateSortField(sort.field)) {
|
||||
return compareRateSortRecords(leftRecord, rightRecord, sort);
|
||||
}
|
||||
|
||||
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
|
||||
}
|
||||
|
||||
function compareRateSortRecords(
|
||||
leftRecord: MarketRecord,
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
const field = sort.field as keyof Required<AfterSearchRates>;
|
||||
const leftValue = leftRecord.rates?.[field];
|
||||
const rightValue = rightRecord.rates?.[field];
|
||||
const leftValue = leftRecord.rates?.[sort.field];
|
||||
const rightValue = rightRecord.rates?.[sort.field];
|
||||
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
|
||||
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
|
||||
|
||||
if (leftLowerBound == null && rightLowerBound == null) {
|
||||
return compareRecordIdentity(leftRecord, rightRecord);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftLowerBound == null) {
|
||||
@ -107,72 +91,5 @@ function compareRateSortRecords(
|
||||
}
|
||||
|
||||
const tieBreak = compareRateValues(leftValue, rightValue);
|
||||
if (tieBreak !== 0) {
|
||||
return sort.direction === "asc" ? tieBreak : -tieBreak;
|
||||
}
|
||||
|
||||
return compareRecordIdentity(leftRecord, rightRecord);
|
||||
}
|
||||
|
||||
function compareBackendMetricRecords(
|
||||
leftRecord: MarketRecord,
|
||||
rightRecord: MarketRecord,
|
||||
sort: MarketSortState
|
||||
): number {
|
||||
const field = sort.field as keyof Required<BackendMetrics>;
|
||||
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
|
||||
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
|
||||
|
||||
if (leftValue == null && rightValue == null) {
|
||||
return compareRecordIdentity(leftRecord, rightRecord);
|
||||
}
|
||||
|
||||
if (leftValue == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightValue == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (leftValue !== rightValue) {
|
||||
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
|
||||
}
|
||||
|
||||
return compareRecordIdentity(leftRecord, rightRecord);
|
||||
}
|
||||
|
||||
function parseBackendMetricValue(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericValue = Number(normalizedValue);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function isRateSortField(
|
||||
field: MarketSortField
|
||||
): field is keyof Required<AfterSearchRates> {
|
||||
return (
|
||||
field === "singleVideoAfterSearchRate" ||
|
||||
field === "personalVideoAfterSearchRate"
|
||||
);
|
||||
}
|
||||
|
||||
function compareRecordIdentity(
|
||||
leftRecord: MarketRecord,
|
||||
rightRecord: MarketRecord
|
||||
): number {
|
||||
const authorIdCompare = leftRecord.authorId.localeCompare(rightRecord.authorId);
|
||||
if (authorIdCompare !== 0) {
|
||||
return authorIdCompare;
|
||||
}
|
||||
|
||||
return leftRecord.authorName.localeCompare(rightRecord.authorName);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,173 +0,0 @@
|
||||
export const MARKET_REQUEST_SNAPSHOT_ATTRIBUTE =
|
||||
"data-sces-market-request-snapshot";
|
||||
const MARKET_SEARCH_ENDPOINT_PATH = "/gw/api/gsearch/search_for_author_square";
|
||||
|
||||
export interface MarketListRequestSnapshot {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function readMarketListRequestSnapshot(
|
||||
document: Document
|
||||
): MarketListRequestSnapshot | null {
|
||||
const serializedSnapshot = document.documentElement.getAttribute(
|
||||
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE
|
||||
);
|
||||
if (!serializedSnapshot) {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedSnapshot = normalizeMarketListRequestSnapshot(
|
||||
JSON.parse(serializedSnapshot)
|
||||
);
|
||||
if (!parsedSnapshot) {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
|
||||
return parsedSnapshot;
|
||||
} catch {
|
||||
return readMarketListRequestSnapshotFromPageState(document);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMarketListRequestSnapshot(
|
||||
document: Document,
|
||||
snapshot: MarketListRequestSnapshot
|
||||
): void {
|
||||
document.documentElement.setAttribute(
|
||||
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
|
||||
JSON.stringify(snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
function isMarketListRequestSnapshot(
|
||||
value: unknown
|
||||
): value is MarketListRequestSnapshot {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MarketListRequestSnapshot>;
|
||||
return (
|
||||
typeof candidate.method === "string" &&
|
||||
typeof candidate.url === "string" &&
|
||||
(!("body" in candidate) || typeof candidate.body === "string") &&
|
||||
(!("headers" in candidate) || isStringRecord(candidate.headers))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMarketListRequestSnapshot(
|
||||
value: unknown
|
||||
): MarketListRequestSnapshot | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MarketListRequestSnapshot> & {
|
||||
headers?: Record<string, unknown>;
|
||||
};
|
||||
const normalizedSnapshot: Partial<MarketListRequestSnapshot> = {
|
||||
body: typeof candidate.body === "string" ? candidate.body : undefined,
|
||||
method: typeof candidate.method === "string" ? candidate.method : undefined,
|
||||
url: typeof candidate.url === "string" ? candidate.url : undefined
|
||||
};
|
||||
|
||||
if (candidate.headers && typeof candidate.headers === "object") {
|
||||
normalizedSnapshot.headers = Object.fromEntries(
|
||||
Object.entries(candidate.headers)
|
||||
.filter(([, entry]) =>
|
||||
["string", "number", "boolean"].includes(typeof entry)
|
||||
)
|
||||
.map(([key, entry]) => [key, String(entry)])
|
||||
);
|
||||
}
|
||||
|
||||
return isMarketListRequestSnapshot(normalizedSnapshot)
|
||||
? normalizedSnapshot
|
||||
: null;
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function readMarketListRequestSnapshotFromPageState(
|
||||
document: Document
|
||||
): MarketListRequestSnapshot | null {
|
||||
const reqParams = findMarketReqParams(document);
|
||||
if (!reqParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify(reqParams),
|
||||
method: "POST",
|
||||
url: buildMarketSearchUrl(document)
|
||||
};
|
||||
}
|
||||
|
||||
function findMarketReqParams(document: Document): Record<string, unknown> | null {
|
||||
const marketRoot = document.querySelector(".base-author-list") as
|
||||
| (Element & {
|
||||
__vue__?: {
|
||||
_setupState?: Record<string, unknown>;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
const setupState = marketRoot?.__vue__?._setupState;
|
||||
if (!setupState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue: unknown[] = Object.values(setupState);
|
||||
while (queue.length > 0) {
|
||||
const current = unwrapVueRef(queue.shift());
|
||||
if (!isRecord(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reqParams = unwrapVueRef(current.reqParams);
|
||||
if (isRecord(reqParams)) {
|
||||
return reqParams;
|
||||
}
|
||||
|
||||
Object.values(current).forEach((value) => {
|
||||
queue.push(value);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMarketSearchUrl(document: Document): string {
|
||||
if (
|
||||
document.location?.origin &&
|
||||
document.location.origin !== "null" &&
|
||||
document.location.origin !== "about:blank"
|
||||
) {
|
||||
return document.location.origin.includes("xingtu.cn")
|
||||
? MARKET_SEARCH_ENDPOINT_PATH
|
||||
: new URL(MARKET_SEARCH_ENDPOINT_PATH, document.location.origin).toString();
|
||||
}
|
||||
|
||||
return MARKET_SEARCH_ENDPOINT_PATH;
|
||||
}
|
||||
|
||||
function unwrapVueRef(value: unknown): unknown {
|
||||
if (isRecord(value) && "value" in value) {
|
||||
return value.value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@ -1,518 +0,0 @@
|
||||
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
|
||||
import type { MarketRowSnapshot } from "./types";
|
||||
|
||||
export interface ParsedMarketListResponse {
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
records: MarketRowSnapshot[];
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
const PAGE_NUMBER_KEYS = [
|
||||
"currentPage",
|
||||
"page",
|
||||
"pageNo",
|
||||
"pageNum",
|
||||
"page_no",
|
||||
"page_num"
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZE_KEYS = [
|
||||
"limit",
|
||||
"pageSize",
|
||||
"page_size",
|
||||
"size"
|
||||
] as const;
|
||||
|
||||
const TOTAL_COUNT_KEYS = [
|
||||
"total",
|
||||
"totalCount",
|
||||
"total_count"
|
||||
] as const;
|
||||
|
||||
const TOTAL_PAGE_KEYS = [
|
||||
"pageCount",
|
||||
"page_count",
|
||||
"totalPage",
|
||||
"totalPages",
|
||||
"total_page",
|
||||
"total_pages"
|
||||
] as const;
|
||||
|
||||
export function mapMarketListRow(
|
||||
row: Record<string, unknown>
|
||||
): MarketRowSnapshot {
|
||||
const attributeDatas = readMarketAttributeDatas(row);
|
||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
||||
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
|
||||
);
|
||||
|
||||
return {
|
||||
authorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "star_id")) ??
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ??
|
||||
"",
|
||||
authorName:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "nickname")) ??
|
||||
readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ??
|
||||
"",
|
||||
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,
|
||||
spreadAuthorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? 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,11 +1,6 @@
|
||||
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 = {
|
||||
@ -29,7 +24,6 @@ function installMarketPageBridge() {
|
||||
}
|
||||
|
||||
window[BRIDGE_MARKER] = true;
|
||||
installMarketRequestSnapshotBridge();
|
||||
syncSerializedMarketRows();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
@ -45,16 +39,7 @@ 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) !==
|
||||
@ -67,131 +52,6 @@ function syncSerializedMarketRows() {
|
||||
}
|
||||
}
|
||||
|
||||
function installFetchSnapshotBridge() {
|
||||
if (typeof window.fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestSnapshot = readFetchSnapshot(input, init);
|
||||
const response = await originalFetch(input, init);
|
||||
if (requestSnapshot) {
|
||||
const clonedResponse = response.clone();
|
||||
void captureMarketSnapshotFromResponse(requestSnapshot, () =>
|
||||
clonedResponse.json()
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
function installXmlHttpRequestSnapshotBridge() {
|
||||
const OriginalXmlHttpRequest = window.XMLHttpRequest;
|
||||
if (!OriginalXmlHttpRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalOpen = OriginalXmlHttpRequest.prototype.open;
|
||||
const originalSend = OriginalXmlHttpRequest.prototype.send;
|
||||
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
|
||||
|
||||
OriginalXmlHttpRequest.prototype.open = function (
|
||||
method: string,
|
||||
url: string | URL,
|
||||
...rest: unknown[]
|
||||
) {
|
||||
(
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot = {
|
||||
headers: {},
|
||||
method,
|
||||
url: String(url)
|
||||
};
|
||||
return originalOpen.call(this, method, url, ...(rest as [boolean?, string?, string?]));
|
||||
};
|
||||
|
||||
OriginalXmlHttpRequest.prototype.setRequestHeader = function (
|
||||
name: string,
|
||||
value: string
|
||||
) {
|
||||
(
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot?.headers &&
|
||||
((this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}).__scesMarketSnapshot!.headers[name] = value);
|
||||
return originalSetRequestHeader.call(this, name, value);
|
||||
};
|
||||
|
||||
OriginalXmlHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
const snapshotState = (
|
||||
this as XMLHttpRequest & {
|
||||
__scesMarketSnapshot?: {
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
).__scesMarketSnapshot;
|
||||
if (snapshotState) {
|
||||
snapshotState.body = typeof body === "string" ? body : undefined;
|
||||
this.addEventListener("load", () => {
|
||||
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
void captureMarketSnapshotFromResponse(snapshotState, async () =>
|
||||
JSON.parse(this.responseText)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return originalSend.call(this, body);
|
||||
};
|
||||
}
|
||||
|
||||
async function captureMarketSnapshotFromResponse(
|
||||
snapshot: {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
},
|
||||
readPayload: () => Promise<unknown>
|
||||
) {
|
||||
if (!isMarketSearchRequest(snapshot.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await readPayload();
|
||||
if (!parseMarketListResponse(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeMarketListRequestSnapshot(document, {
|
||||
body: snapshot.body,
|
||||
headers: snapshot.headers,
|
||||
method: snapshot.method,
|
||||
url: snapshot.url
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readSerializedMarketRows() {
|
||||
const marketList = readMarketList();
|
||||
return marketList
|
||||
@ -205,67 +65,13 @@ 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,
|
||||
spreadAuthorId: readString(attributeDatas.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__?: {
|
||||
@ -296,15 +102,6 @@ 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,7 @@
|
||||
import type {
|
||||
BackendMetrics,
|
||||
MarketApiFailureReason,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
SpreadInfoMetrics
|
||||
MarketRowSnapshot
|
||||
} from "./types";
|
||||
import type { AfterSearchRates } from "./types";
|
||||
|
||||
@ -27,33 +25,6 @@ export function createMarketResultStore() {
|
||||
existingRecord.status = "loading";
|
||||
delete existingRecord.failureReason;
|
||||
},
|
||||
setBackendMetricsFailed(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "failed";
|
||||
},
|
||||
setBackendMetricsLoading(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "loading";
|
||||
},
|
||||
setBackendMetricsMissing(authorId: string) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "missing";
|
||||
},
|
||||
setBackendMetricsSuccess(authorId: string, backendMetrics: BackendMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.backendMetricsStatus = "success";
|
||||
existingRecord.backendMetrics = {
|
||||
...existingRecord.backendMetrics,
|
||||
...backendMetrics
|
||||
};
|
||||
},
|
||||
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.spreadMetrics = {
|
||||
...existingRecord.spreadMetrics,
|
||||
...spreadMetrics
|
||||
};
|
||||
},
|
||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.status = "success";
|
||||
@ -69,10 +40,6 @@ 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
|
||||
@ -81,22 +48,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.price21To60s,
|
||||
row.price21To60s
|
||||
);
|
||||
existingRecord.spreadAuthorId = mergeStringValue(
|
||||
existingRecord.spreadAuthorId,
|
||||
row.spreadAuthorId
|
||||
);
|
||||
existingRecord.exportFields = mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
row.exportFields
|
||||
);
|
||||
existingRecord.backendMetrics = mergeFieldMap(
|
||||
existingRecord.backendMetrics,
|
||||
row.backendMetrics
|
||||
);
|
||||
existingRecord.spreadMetrics = mergeFieldMap(
|
||||
existingRecord.spreadMetrics,
|
||||
row.spreadMetrics
|
||||
);
|
||||
existingRecord.hasDirectRatesSource =
|
||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||
@ -105,7 +60,6 @@ export function createMarketResultStore() {
|
||||
|
||||
const nextRecord: MarketRecord = {
|
||||
...row,
|
||||
backendMetricsStatus: "idle",
|
||||
status: "idle"
|
||||
};
|
||||
records.set(row.authorId, nextRecord);
|
||||
@ -122,7 +76,6 @@ export function createMarketResultStore() {
|
||||
const nextRecord: MarketRecord = {
|
||||
authorId,
|
||||
authorName: authorId,
|
||||
backendMetricsStatus: "idle",
|
||||
status: "idle"
|
||||
};
|
||||
records.set(authorId, nextRecord);
|
||||
|
||||
@ -1,402 +0,0 @@
|
||||
import {
|
||||
readMarketListRequestSnapshot,
|
||||
type MarketListRequestSnapshot
|
||||
} from "./market-list-request-snapshot";
|
||||
import {
|
||||
parseMarketListResponse,
|
||||
readKnownPaginationNumber
|
||||
} from "./market-list-row";
|
||||
import type { MarketExportTarget, MarketRecord } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
interface SilentExportControllerOptions {
|
||||
document: Document;
|
||||
fetchImpl?: FetchLike;
|
||||
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||
}
|
||||
|
||||
type PageSource = "body" | "none" | "url";
|
||||
|
||||
const PAGE_NUMBER_KEYS = [
|
||||
"currentPage",
|
||||
"page",
|
||||
"pageNo",
|
||||
"pageNum",
|
||||
"page_no",
|
||||
"page_num"
|
||||
] as const;
|
||||
|
||||
export function createSilentExportController(
|
||||
options: SilentExportControllerOptions
|
||||
) {
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
|
||||
return {
|
||||
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[] | null> {
|
||||
const snapshot = readMarketListRequestSnapshot(options.document);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRequest = createPagedRequest(snapshot);
|
||||
if (!baseRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedRecords = new Map<string, MarketRecord>();
|
||||
const maxPageCount = target.mode === "count" ? target.pageCount : 200;
|
||||
let totalPagesHint: number | undefined;
|
||||
|
||||
for (let offset = 0; offset < maxPageCount; offset += 1) {
|
||||
const pageNumber = baseRequest.initialPage + offset;
|
||||
options.onProgress?.({
|
||||
currentPage: offset + 1,
|
||||
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
|
||||
});
|
||||
|
||||
const payload = await fetchPagePayload(fetchImpl, baseRequest, pageNumber);
|
||||
const parsedResponse = parseMarketListResponse(payload);
|
||||
if (!parsedResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
totalPagesHint = parsedResponse.totalPages ?? totalPagesHint;
|
||||
if (parsedResponse.records.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
parsedResponse.records.forEach((record) => {
|
||||
const existingRecord = mergedRecords.get(record.authorId);
|
||||
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
||||
});
|
||||
|
||||
if (target.mode === "count" && offset + 1 >= target.pageCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (target.mode === "all") {
|
||||
if (
|
||||
typeof parsedResponse.totalPages === "number" &&
|
||||
pageNumber >= parsedResponse.totalPages
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsedResponse.pageSize === "number" &&
|
||||
parsedResponse.records.length < parsedResponse.pageSize
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mergedRecords.values());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPagedRequest(
|
||||
snapshot: MarketListRequestSnapshot
|
||||
): {
|
||||
initialPage: number;
|
||||
pageSource: PageSource;
|
||||
snapshot: MarketListRequestSnapshot;
|
||||
} {
|
||||
const bodyPage = readPageFromBody(snapshot.body);
|
||||
if (bodyPage !== null) {
|
||||
return {
|
||||
initialPage: bodyPage,
|
||||
pageSource: "body",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
const urlPage = readPageFromUrl(snapshot.url);
|
||||
if (urlPage !== null) {
|
||||
return {
|
||||
initialPage: urlPage,
|
||||
pageSource: "url",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initialPage: 1,
|
||||
pageSource: "none",
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPagePayload(
|
||||
fetchImpl: FetchLike,
|
||||
request: {
|
||||
pageSource: PageSource;
|
||||
snapshot: MarketListRequestSnapshot;
|
||||
},
|
||||
pageNumber: number
|
||||
): Promise<unknown> {
|
||||
const nextUrl =
|
||||
request.pageSource === "url"
|
||||
? mutateUrlPage(request.snapshot.url, pageNumber)
|
||||
: request.snapshot.url;
|
||||
const nextBody = mutateBodyPage(request.snapshot.body, pageNumber);
|
||||
|
||||
const response = await fetchImpl(nextUrl, {
|
||||
body: nextBody,
|
||||
credentials: "include",
|
||||
headers: filterReplayHeaders(request.snapshot.headers, nextBody),
|
||||
method: request.snapshot.method
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("静默导出请求失败");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function readPageFromUrl(url: string): number | null {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
const value = readNumericString(parsedUrl.searchParams.get(key));
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mutateUrlPage(url: string, pageNumber: number): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
if (!parsedUrl.searchParams.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set(key, String(pageNumber));
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set("page", String(pageNumber));
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function readPageFromBody(body: string | undefined): number | null {
|
||||
const parsedBody = parseBody(body);
|
||||
if (!parsedBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readKnownPaginationNumber(parsedBody, "page");
|
||||
}
|
||||
|
||||
function mutateBodyPage(body: string | undefined, pageNumber: number): string | undefined {
|
||||
if (!body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const trimmedBody = body.trim();
|
||||
if (!trimmedBody) {
|
||||
return body;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmedBody);
|
||||
if (!replacePageNumberInValue(parsedJson, pageNumber) && isRecord(parsedJson)) {
|
||||
parsedJson.page = pageNumber;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsedJson);
|
||||
} catch {
|
||||
const searchParams = new URLSearchParams(trimmedBody);
|
||||
for (const key of PAGE_NUMBER_KEYS) {
|
||||
if (!searchParams.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
searchParams.set(key, String(pageNumber));
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
searchParams.set("page", String(pageNumber));
|
||||
return searchParams.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function parseBody(body: string | undefined): Record<string, unknown> | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedBody = body.trim();
|
||||
if (!trimmedBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedBody = JSON.parse(trimmedBody);
|
||||
return isRecord(parsedBody) ? parsedBody : null;
|
||||
} catch {
|
||||
const searchParams = new URLSearchParams(trimmedBody);
|
||||
const payload: Record<string, unknown> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
payload[key] = value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
function replacePageNumberInValue(value: unknown, pageNumber: number): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let replaced = false;
|
||||
PAGE_NUMBER_KEYS.forEach((key) => {
|
||||
if (!(key in value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value[key] = pageNumber;
|
||||
replaced = true;
|
||||
});
|
||||
|
||||
if (replaced) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(value).some((entry) => replacePageNumberInValue(entry, pageNumber));
|
||||
}
|
||||
|
||||
function filterReplayHeaders(
|
||||
headers: Record<string, string> | undefined,
|
||||
body: string | undefined
|
||||
): HeadersInit | undefined {
|
||||
const filteredHeaders = Object.fromEntries(
|
||||
Object.entries(headers ?? {}).filter(([key]) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
return normalizedKey !== "content-length" && normalizedKey !== "host";
|
||||
})
|
||||
);
|
||||
|
||||
if (body) {
|
||||
if (!hasHeader(filteredHeaders, "accept")) {
|
||||
filteredHeaders.Accept = "application/json, text/plain, */*";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "content-type")) {
|
||||
filteredHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "x-login-source")) {
|
||||
filteredHeaders["x-login-source"] = "1";
|
||||
}
|
||||
if (!hasHeader(filteredHeaders, "agw-js-conv")) {
|
||||
filteredHeaders["Agw-Js-Conv"] = "str";
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(filteredHeaders).length > 0 ? filteredHeaders : undefined;
|
||||
}
|
||||
|
||||
function hasHeader(headers: Record<string, string>, key: string): boolean {
|
||||
return Object.keys(headers).some((headerKey) => headerKey.toLowerCase() === key);
|
||||
}
|
||||
|
||||
function readNumericString(value: string | null): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function mergeMarketRecord(
|
||||
existingRecord: MarketRecord | undefined,
|
||||
incomingRecord: MarketRecord
|
||||
): MarketRecord {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
...incomingRecord,
|
||||
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
|
||||
rates: mergeFieldMap(undefined, incomingRecord.rates),
|
||||
status: incomingRecord.status ?? "idle"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||
coreUserId: mergeStringValue(existingRecord.coreUserId, incomingRecord.coreUserId),
|
||||
exportFields: mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
incomingRecord.exportFields
|
||||
),
|
||||
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
|
||||
hasDirectRatesSource:
|
||||
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
|
||||
location: mergeStringValue(existingRecord.location, incomingRecord.location),
|
||||
price21To60s: mergeStringValue(
|
||||
existingRecord.price21To60s,
|
||||
incomingRecord.price21To60s
|
||||
),
|
||||
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
|
||||
status: incomingRecord.status ?? existingRecord.status
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||
current: T | undefined,
|
||||
incoming: T | undefined
|
||||
): T | undefined {
|
||||
if (!current && !incoming) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...(current ?? {})
|
||||
} as Record<string, string | undefined>;
|
||||
|
||||
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||
const currentValue = merged[key];
|
||||
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function mergeStringValue(
|
||||
current: string | undefined,
|
||||
incoming: string | undefined
|
||||
): string | undefined {
|
||||
return hasTextValue(incoming) ? incoming : current;
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): boolean {
|
||||
return Boolean(value && value.trim().length > 0);
|
||||
}
|
||||
@ -1,349 +0,0 @@
|
||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
interface SpreadInfoClientOptions {
|
||||
baseUrl?: string;
|
||||
configs?: SpreadInfoConfig[];
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SpreadInfoMetricDefinition {
|
||||
key: keyof MappedSpreadInfoResponse;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MappedSpreadInfoResponse {
|
||||
averageCommentCount?: string;
|
||||
averageDuration?: string;
|
||||
averageLikeCount?: string;
|
||||
averageShareCount?: string;
|
||||
finishRate?: string;
|
||||
interactionRate?: string;
|
||||
playMedian?: string;
|
||||
}
|
||||
|
||||
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
||||
{
|
||||
key: "finishRate",
|
||||
label: "完播率"
|
||||
},
|
||||
{
|
||||
key: "playMedian",
|
||||
label: "播放量中位数"
|
||||
},
|
||||
{
|
||||
key: "interactionRate",
|
||||
label: "互动率"
|
||||
},
|
||||
{
|
||||
key: "averageDuration",
|
||||
label: "作品平均时长"
|
||||
},
|
||||
{
|
||||
key: "averageCommentCount",
|
||||
label: "作品平均评论数"
|
||||
},
|
||||
{
|
||||
key: "averageLikeCount",
|
||||
label: "作品平均点赞数"
|
||||
},
|
||||
{
|
||||
key: "averageShareCount",
|
||||
label: "作品平均转发数"
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
},
|
||||
...buildXingtuVideoConfigs()
|
||||
];
|
||||
|
||||
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of configs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
||||
([header, value]) => {
|
||||
metrics[header] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
},
|
||||
async loadAuthorSpreadMetricSnapshot(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSpreadInfoFromUrl(
|
||||
url: string
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
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 {};
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(await response.json());
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpreadInfoUrl(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig,
|
||||
baseUrl: string
|
||||
): 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(config.type));
|
||||
url.searchParams.set("flow_type", String(config.flowType));
|
||||
url.searchParams.set("only_assign", String(config.onlyAssign));
|
||||
url.searchParams.set("range", String(config.range));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildSpreadInfoColumns(
|
||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||
): string[] {
|
||||
return configs.flatMap((config) =>
|
||||
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpreadInfoMetricMap(
|
||||
config: SpreadInfoConfig,
|
||||
metrics: MappedSpreadInfoResponse
|
||||
): SpreadInfoMetrics {
|
||||
const values: SpreadInfoMetrics = {};
|
||||
|
||||
SPREAD_INFO_METRICS.forEach((metric) => {
|
||||
const value = metrics[metric.key];
|
||||
if (hasTextValue(value)) {
|
||||
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function mapSpreadInfoResponse(
|
||||
payload: unknown
|
||||
): MappedSpreadInfoResponse {
|
||||
const data = getPayloadData(payload);
|
||||
if (!data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
averageCommentCount: readStringLike(data.comment_avg),
|
||||
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
||||
averageLikeCount: readStringLike(data.like_avg),
|
||||
averageShareCount: readStringLike(data.share_avg),
|
||||
finishRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
||||
),
|
||||
interactionRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
||||
),
|
||||
playMedian:
|
||||
readStringLike(data.play_mid) ??
|
||||
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSpreadThresholds(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
thresholds: SpreadMetricThresholds
|
||||
): boolean {
|
||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
||||
const numericValue = readDisplayNumber(metricValue);
|
||||
return numericValue !== null && numericValue >= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
config: SpreadInfoConfig,
|
||||
metric: SpreadInfoMetricDefinition
|
||||
): string {
|
||||
return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-");
|
||||
}
|
||||
|
||||
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
||||
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
||||
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
||||
|
||||
if (config.type === 1) {
|
||||
return [typeLabel, rangeLabel];
|
||||
}
|
||||
|
||||
return [
|
||||
config.onlyAssign ? "只看指派" : "不限指派",
|
||||
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
||||
typeLabel,
|
||||
rangeLabel
|
||||
];
|
||||
}
|
||||
|
||||
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
||||
const configs: SpreadInfoConfig[] = [];
|
||||
[false, true].forEach((onlyAssign) => {
|
||||
([0, 1] as const).forEach((flowType) => {
|
||||
([2, 3] as const).forEach((range) => {
|
||||
configs.push({
|
||||
flowType,
|
||||
onlyAssign,
|
||||
range,
|
||||
type: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return configs;
|
||||
}
|
||||
|
||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isRecord(payload.data) ? payload.data : payload;
|
||||
}
|
||||
|
||||
function readNestedValue(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function readStringLike(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDisplayNumber(value: string | undefined): number | null {
|
||||
if (!hasTextValue(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100)}%`;
|
||||
}
|
||||
|
||||
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatDecimal(value / 100);
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://www.xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@ -3,59 +3,19 @@ export interface AfterSearchRates {
|
||||
singleVideoAfterSearchRate?: string;
|
||||
}
|
||||
|
||||
export interface BackendMetrics {
|
||||
a3IncreaseCount?: string;
|
||||
afterViewSearchCount?: string;
|
||||
afterViewSearchRate?: string;
|
||||
cpSearch?: string;
|
||||
cpa3?: string;
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadMetricThresholds {
|
||||
averageCommentCount?: number;
|
||||
averageDuration?: number;
|
||||
averageLikeCount?: number;
|
||||
averageShareCount?: number;
|
||||
finishRate?: number;
|
||||
interactionRate?: number;
|
||||
playMedian?: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
config: {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
};
|
||||
thresholds: SpreadMetricThresholds;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
|
||||
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
|
||||
|
||||
export interface MarketRowSnapshot {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
backendMetrics?: BackendMetrics;
|
||||
coreUserId?: string;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource?: boolean;
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
spreadMetrics?: SpreadInfoMetrics;
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
backendMetricsStatus?: MarketRecordStatus;
|
||||
status: MarketRecordStatus;
|
||||
failureReason?: MarketApiFailureReason;
|
||||
}
|
||||
@ -78,7 +38,7 @@ export type MarketExportTarget =
|
||||
|
||||
export interface MarketSortState {
|
||||
direction: "asc" | "desc";
|
||||
field: MarketSortField;
|
||||
field: keyof Required<AfterSearchRates>;
|
||||
}
|
||||
|
||||
export type MarketApiFailureReason =
|
||||
@ -89,7 +49,7 @@ export type MarketApiFailureReason =
|
||||
|
||||
export type MarketApiSuccessResult = {
|
||||
success: true;
|
||||
rates: AfterSearchRates;
|
||||
rates: Required<AfterSearchRates>;
|
||||
};
|
||||
|
||||
export type MarketApiFailureResult = {
|
||||
|
||||
@ -3,27 +3,7 @@
|
||||
"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/*",
|
||||
"http://127.0.0.1:4319/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "assets/icons/icon-16.png",
|
||||
"32": "assets/icons/icon-32.png"
|
||||
},
|
||||
"default_popup": "popup/index.html"
|
||||
},
|
||||
"permissions": ["downloads"],
|
||||
"background": {
|
||||
"service_worker": "background/index.js"
|
||||
},
|
||||
@ -34,7 +14,7 @@
|
||||
"https://*.xingtu.cn/ad/creator/market*"
|
||||
],
|
||||
"js": ["content/index.js"],
|
||||
"run_at": "document_start"
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Chart Search Enhancer</title>
|
||||
<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>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,288 +0,0 @@
|
||||
import {
|
||||
renderDevPanel,
|
||||
renderLoggedIn,
|
||||
renderLoggedOut,
|
||||
renderUpdateStatus,
|
||||
setUpdateDownloadStatus,
|
||||
setProtectedApiResult
|
||||
} from "./view";
|
||||
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||
import {
|
||||
isAuthResponseMessage,
|
||||
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;
|
||||
|
||||
if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) {
|
||||
throw new Error("popup root #app is required");
|
||||
}
|
||||
|
||||
const sendMessage =
|
||||
options.sendMessage ??
|
||||
((message: unknown) =>
|
||||
Promise.resolve(
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
chrome?: {
|
||||
runtime?: {
|
||||
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome?.runtime?.sendMessage?.(message)
|
||||
));
|
||||
const fetchProtectedApi =
|
||||
options.fetchProtectedApi ??
|
||||
createProtectedApiClient({
|
||||
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, {
|
||||
currentVersion,
|
||||
fetchUpdateManifest
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCurrentAuthState(
|
||||
root: HTMLElement,
|
||||
popupConfig: AuthConfig,
|
||||
sendMessage: (message: unknown) => 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") {
|
||||
renderLoggedOut(root, "认证状态读取失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.value.isAuthenticated) {
|
||||
renderLoggedOut(root, response.value.lastError);
|
||||
root
|
||||
.querySelector('[data-popup-sign-in="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, {
|
||||
actionMessage: { type: "auth:sign-in" },
|
||||
fetchProtectedApi,
|
||||
updateOptions
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoggedIn(root, response.value);
|
||||
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,
|
||||
updateOptions
|
||||
});
|
||||
});
|
||||
if (popupConfig.enableDevAuthPanel) {
|
||||
renderDevPanel(root, response.value);
|
||||
root
|
||||
.querySelector('[data-popup-test-protected-api="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runProtectedApiProbe(root, fetchProtectedApi);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runAuthAction(
|
||||
root: HTMLElement,
|
||||
popupConfig: AuthConfig,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
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);
|
||||
|
||||
if (isActionError(response)) {
|
||||
renderLoggedOut(root, response.error);
|
||||
root
|
||||
.querySelector('[data-popup-sign-in="button"]')
|
||||
?.addEventListener("click", () => {
|
||||
void runAuthAction(root, popupConfig, sendMessage, options);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await renderCurrentAuthState(
|
||||
root,
|
||||
popupConfig,
|
||||
sendMessage,
|
||||
options.fetchProtectedApi,
|
||||
options.updateOptions
|
||||
);
|
||||
}
|
||||
|
||||
function isActionError(response: unknown): response is Extract<AuthResponseMessage, { ok: false }> {
|
||||
return (
|
||||
isAuthResponseMessage(response) &&
|
||||
!response.ok &&
|
||||
response.type === "auth:error"
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
): Promise<void> {
|
||||
setProtectedApiResult(root, "请求中...");
|
||||
|
||||
try {
|
||||
const result = await fetchProtectedApi();
|
||||
setProtectedApiResult(root, JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
setProtectedApiResult(
|
||||
root,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
void bootPopup();
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
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 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>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLoggedIn(
|
||||
root: HTMLElement,
|
||||
authState: AuthStateValue
|
||||
): void {
|
||||
const userInfo = authState.userInfo;
|
||||
|
||||
root.innerHTML = `
|
||||
<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>
|
||||
</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 = `
|
||||
<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);
|
||||
}
|
||||
|
||||
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||
const output = root.querySelector(
|
||||
'[data-popup-protected-api-result="output"]'
|
||||
);
|
||||
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.textContent = value;
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
export interface AuthConfig {
|
||||
apiResource: string;
|
||||
appId: string;
|
||||
enableDevAuthPanel: boolean;
|
||||
logtoEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
const defaultAuthConfig: AuthConfig = {
|
||||
apiResource: "https://talent-search.intelligrow.cn",
|
||||
appId: "i4jkllbvih0554r4n0fd3",
|
||||
enableDevAuthPanel: false,
|
||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
||||
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
|
||||
};
|
||||
|
||||
export function readAuthConfig(
|
||||
overrides: Partial<AuthConfig> = {}
|
||||
): AuthConfig {
|
||||
const nextConfig = {
|
||||
...defaultAuthConfig,
|
||||
...overrides
|
||||
};
|
||||
|
||||
if (!nextConfig.logtoEndpoint.trim()) {
|
||||
throw new Error("auth config logtoEndpoint is required");
|
||||
}
|
||||
|
||||
if (!nextConfig.appId.trim()) {
|
||||
throw new Error("auth config appId is required");
|
||||
}
|
||||
|
||||
if (!nextConfig.apiResource.trim()) {
|
||||
throw new Error("auth config apiResource is required");
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
export type AuthRequestMessage =
|
||||
| { type: "auth:get-state" }
|
||||
| { type: "auth:sign-in" }
|
||||
| { type: "auth:sign-out" }
|
||||
| { type: "auth:get-access-token" };
|
||||
|
||||
export interface AuthStateValue {
|
||||
accessTokenExpiresAt?: number | null;
|
||||
isAuthenticated: boolean;
|
||||
lastError?: string | null;
|
||||
resource?: string | null;
|
||||
scopes?: string[];
|
||||
tokenAvailable?: boolean;
|
||||
userInfo?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AuthResponseMessage =
|
||||
| { ok: true; type: "auth:state"; value: AuthStateValue }
|
||||
| { ok: true; type: "auth:token"; value: { accessToken: string } }
|
||||
| { ok: true; type: "auth:ack" }
|
||||
| { ok: false; type: "auth:error"; error: string };
|
||||
|
||||
const authRequestTypes = new Set<AuthRequestMessage["type"]>([
|
||||
"auth:get-state",
|
||||
"auth:sign-in",
|
||||
"auth:sign-out",
|
||||
"auth:get-access-token"
|
||||
]);
|
||||
|
||||
export function isAuthRequestMessage(
|
||||
value: unknown
|
||||
): value is AuthRequestMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AuthRequestMessage>;
|
||||
return typeof candidate.type === "string" && authRequestTypes.has(candidate.type);
|
||||
}
|
||||
|
||||
export function isAuthResponseMessage(
|
||||
value: unknown
|
||||
): value is AuthResponseMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AuthResponseMessage>;
|
||||
if (candidate.ok === false) {
|
||||
return candidate.type === "auth:error" && typeof candidate.error === "string";
|
||||
}
|
||||
|
||||
if (candidate.ok !== true || typeof candidate.type !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:ack") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:token") {
|
||||
return Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
typeof (candidate.value as { accessToken?: unknown }).accessToken === "string"
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "auth:state") {
|
||||
return Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
typeof (candidate.value as { isAuthenticated?: unknown }).isAuthenticated ===
|
||||
"boolean"
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@ -1,217 +0,0 @@
|
||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "./backend-metrics-config";
|
||||
|
||||
export interface BackendMetricsRow {
|
||||
a3IncreaseCount: string;
|
||||
afterViewSearchCount: string;
|
||||
afterViewSearchRate: string;
|
||||
cpSearch: string;
|
||||
cpa3: string;
|
||||
newA3Rate: string;
|
||||
starId: string;
|
||||
}
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
interface BackendMetricsClientOptions {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
export function createBackendMetricsClient(options: BackendMetricsClientOptions) {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BACKEND_METRICS_BASE_URL;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
|
||||
return {
|
||||
async searchByStarIds(starIds: string[]): Promise<BackendMetricsRow[]> {
|
||||
const response = await fetchImpl(buildBackendMetricsSearchUrl(baseUrl), {
|
||||
body: JSON.stringify(buildBackendMetricsSearchRequestBody(starIds)),
|
||||
headers: {
|
||||
Authorization: `Bearer ${await options.getAccessToken()}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("backend metrics request failed");
|
||||
}
|
||||
|
||||
return mapBackendMetricsSearchResponse(await response.json());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBackendMetricsSearchUrl(baseUrl: string): string {
|
||||
return new URL("/api/v1/history/talents/search", baseUrl).toString();
|
||||
}
|
||||
|
||||
export function buildBackendMetricsSearchRequestBody(starIds: string[]) {
|
||||
return {
|
||||
page: 1,
|
||||
size: Math.max(20, starIds.length),
|
||||
type: "star_id",
|
||||
values: starIds
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetricsRow[] {
|
||||
const rows = readResponseRows(payload);
|
||||
if (!rows) {
|
||||
throw new Error("backend metrics response is invalid");
|
||||
}
|
||||
|
||||
return rows.flatMap((row) => {
|
||||
if (!isRecord(row) || typeof row.star_id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
a3IncreaseCount: formatDecimalValue(
|
||||
readAverageA3IncreaseCount(row)
|
||||
),
|
||||
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
|
||||
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
|
||||
cpSearch: formatDecimalValue(row.cp_search),
|
||||
cpa3: formatDecimalValue(readCpa3Value(row)),
|
||||
newA3Rate: formatRateValue(row.avg_new_a3_rate),
|
||||
starId: row.star_id
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function readAverageA3IncreaseCount(row: Record<string, unknown>): number | null {
|
||||
const directAverage = readFiniteNumber(row.avg_a3_increase_cnt);
|
||||
if (directAverage !== null) {
|
||||
return directAverage;
|
||||
}
|
||||
|
||||
const totalNewA3 = readTotalNewA3Value(row);
|
||||
const videoCount =
|
||||
readFiniteNumber(row.video_count) ?? readNestedVideoCount(row.videos);
|
||||
if (totalNewA3 === null || videoCount === null || videoCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return totalNewA3 / videoCount;
|
||||
}
|
||||
|
||||
function readCpa3Value(row: Record<string, unknown>): number | null {
|
||||
const directCpa3 = readFiniteNumber(row.cpa3);
|
||||
if (directCpa3 !== null) {
|
||||
return directCpa3;
|
||||
}
|
||||
|
||||
const totalCost = readFiniteNumber(row.total_estimated_video_cost);
|
||||
const totalNewA3 = readTotalNewA3Value(row);
|
||||
if (totalCost === null || totalNewA3 === null || totalNewA3 <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return totalCost / totalNewA3;
|
||||
}
|
||||
|
||||
function readTotalNewA3Value(row: Record<string, unknown>): number | null {
|
||||
const derivedFromTotals = deriveTotalNewA3FromTotals(row);
|
||||
if (derivedFromTotals !== null) {
|
||||
return derivedFromTotals;
|
||||
}
|
||||
|
||||
return deriveTotalNewA3FromVideos(row.videos);
|
||||
}
|
||||
|
||||
function deriveTotalNewA3FromTotals(row: Record<string, unknown>): number | null {
|
||||
const totalPlayCount = readFiniteNumber(row.total_play_cnt);
|
||||
const averageNewA3Rate = readFiniteNumber(row.avg_new_a3_rate);
|
||||
if (totalPlayCount === null || averageNewA3Rate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return totalPlayCount * averageNewA3Rate;
|
||||
}
|
||||
|
||||
function deriveTotalNewA3FromVideos(value: unknown): number | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let hasFiniteValue = false;
|
||||
value.forEach((video) => {
|
||||
if (!isRecord(video)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newA3 = readFiniteNumber(video.new_a3);
|
||||
if (newA3 === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasFiniteValue = true;
|
||||
total += newA3;
|
||||
});
|
||||
|
||||
return hasFiniteValue ? total : null;
|
||||
}
|
||||
|
||||
function readNestedVideoCount(value: unknown): number | null {
|
||||
return Array.isArray(value) ? value.length : null;
|
||||
}
|
||||
|
||||
function readResponseRows(payload: unknown): unknown[] | null {
|
||||
if (!isRecord(payload) || payload.success !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topLevelData = isRecord(payload.data) ? payload.data : null;
|
||||
return Array.isArray(topLevelData?.data) ? topLevelData.data : null;
|
||||
}
|
||||
|
||||
function formatRateValue(value: unknown): string {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
if (Number.isFinite(number)) {
|
||||
const percentage = number * 100;
|
||||
const formatted = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: percentage % 1 === 0 ? 0 : 2
|
||||
}).format(percentage);
|
||||
return `${formatted}%`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatDecimalValue(value: unknown): string {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
}).format(number);
|
||||
}
|
||||
|
||||
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 readFiniteNumber(value: unknown): number | null {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export const DEFAULT_BACKEND_METRICS_BASE_URL =
|
||||
"https://talent-search.intelligrow.cn";
|
||||
@ -1,67 +0,0 @@
|
||||
import type { BackendMetricsRow } from "./backend-metrics-client";
|
||||
|
||||
export type BackendMetricsSearchRequestMessage = {
|
||||
type: "backend-metrics:search";
|
||||
value: {
|
||||
starIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type BackendMetricsResponseMessage =
|
||||
| {
|
||||
ok: true;
|
||||
type: "backend-metrics:result";
|
||||
value: {
|
||||
rows: BackendMetricsRow[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
ok: false;
|
||||
type: "backend-metrics:error";
|
||||
};
|
||||
|
||||
export function isBackendMetricsSearchRequestMessage(
|
||||
value: unknown
|
||||
): value is BackendMetricsSearchRequestMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendMetricsSearchRequestMessage>;
|
||||
return (
|
||||
candidate.type === "backend-metrics:search" &&
|
||||
Boolean(
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
Array.isArray((candidate.value as { starIds?: unknown }).starIds) &&
|
||||
(candidate.value as { starIds: unknown[] }).starIds.every(
|
||||
(starId) => typeof starId === "string"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isBackendMetricsResponseMessage(
|
||||
value: unknown
|
||||
): value is BackendMetricsResponseMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendMetricsResponseMessage>;
|
||||
if (candidate.ok === false) {
|
||||
return (
|
||||
candidate.type === "backend-metrics:error" &&
|
||||
typeof candidate.error === "string"
|
||||
);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
candidate.ok === true &&
|
||||
candidate.type === "backend-metrics:result" &&
|
||||
candidate.value &&
|
||||
typeof candidate.value === "object" &&
|
||||
Array.isArray((candidate.value as { rows?: unknown }).rows)
|
||||
);
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import type { BatchPayload } from "../content/market/batch-payload";
|
||||
import { isAuthResponseMessage } from "./auth-messages";
|
||||
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "./batch-submit-config";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
type GetAccessTokenLike = () => Promise<string>;
|
||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||
|
||||
export function createBatchSubmitClient(options: {
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
getAccessToken?: GetAccessTokenLike;
|
||||
sendMessage: SendMessageLike;
|
||||
}) {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BATCH_SUBMIT_BASE_URL;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const getAccessToken =
|
||||
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
|
||||
|
||||
return {
|
||||
async submitBatch(payload: BatchPayload) {
|
||||
const token = await getAccessToken();
|
||||
const response = await fetchImpl(
|
||||
buildBatchSubmitUrl(baseUrl),
|
||||
{
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("batch submit unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`batch submit failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return readBatchSubmitResponse(await response.json());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBatchSubmitUrl(baseUrl: string): string {
|
||||
return new URL("/api/v1/batch-status/batches", baseUrl).toString();
|
||||
}
|
||||
|
||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||
|
||||
if (
|
||||
!isAuthResponseMessage(response) ||
|
||||
!response.ok ||
|
||||
response.type !== "auth:token" ||
|
||||
!response.value.accessToken.trim()
|
||||
) {
|
||||
throw new Error("batch submit token unavailable");
|
||||
}
|
||||
|
||||
return response.value.accessToken;
|
||||
}
|
||||
|
||||
function readBatchSubmitResponse(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("batch submit response is invalid");
|
||||
}
|
||||
|
||||
if (payload.success !== true) {
|
||||
const message =
|
||||
typeof payload.msg === "string" && payload.msg.trim()
|
||||
? payload.msg
|
||||
: "batch submit failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return "data" in payload ? payload.data : payload;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user