release: 0.2.0421.2
This commit is contained in:
parent
55324a5bb7
commit
968cc88c62
217
docs/superpowers/plans/2026-04-21-market-export-range.md
Normal file
217
docs/superpowers/plans/2026-04-21-market-export-range.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# Market Export Range Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts.
|
||||||
|
|
||||||
|
**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extend Toolbar Controls For Export Range
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||||
|
- Test: `tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests that assert:
|
||||||
|
- the toolbar defaults to `前5页`
|
||||||
|
- selecting `自定义` shows and uses a page-count input
|
||||||
|
- invalid custom values prevent export
|
||||||
|
- export mode disables toolbar controls during a running task
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
|
||||||
|
Expected: FAIL because the toolbar has no range selector or custom input behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal implementation**
|
||||||
|
|
||||||
|
Update `plugin-toolbar.ts` to:
|
||||||
|
- add an export-range `<select>`
|
||||||
|
- add a custom page-count `<input>`
|
||||||
|
- add status text support
|
||||||
|
- expose helpers for reading range state and toggling disabled/running UI
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||||
|
git commit -m "feat: add export range toolbar controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add Batch Export Coverage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/market-content-entry.test.ts`
|
||||||
|
- Modify: `tests/market-dom-sync.test.ts`
|
||||||
|
- Test: `tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests that assert:
|
||||||
|
- `当前页` exports only the current page
|
||||||
|
- `前5页` iterates through pages until it reaches page 5 or runs out of pages
|
||||||
|
- `全部` stops when the next-page control is no longer usable
|
||||||
|
- repeated authors across pages are merged by `authorId`
|
||||||
|
- empty fields from later pages do not erase existing populated fields
|
||||||
|
- page export waits for page signature change before harvesting the next page
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
Expected: FAIL on the new batch-export expectations.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal implementation scaffolding**
|
||||||
|
|
||||||
|
Only introduce the minimum new helpers/types needed to make the tests compile:
|
||||||
|
- export range type(s)
|
||||||
|
- page signature helpers
|
||||||
|
- harness utilities in tests
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify the failures are now behavioral**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
Expected: FAIL because batch export logic is still missing, not because types/selectors are missing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts
|
||||||
|
git commit -m "test: cover multi-page export behavior"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement Batch Export Controller
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/content/market/export-range-controller.ts`
|
||||||
|
- Modify: `src/content/market/types.ts`
|
||||||
|
- Modify: `src/content/market/dom-sync.ts`
|
||||||
|
- Modify: `src/content/market/result-store.ts`
|
||||||
|
- Test: `tests/market-content-entry.test.ts`
|
||||||
|
- Test: `tests/market-dom-sync.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add focused tests for:
|
||||||
|
- reading a page signature from current DOM
|
||||||
|
- detecting/using the next-page control
|
||||||
|
- merging rows by `authorId` with non-empty-field preference
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||||
|
Expected: FAIL because these helpers/controller do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal implementation**
|
||||||
|
|
||||||
|
Implement `export-range-controller.ts` to:
|
||||||
|
- normalize export targets (`current`, `count`, `all`)
|
||||||
|
- harvest current page rows with the existing lazy-load scroll path
|
||||||
|
- read page signatures
|
||||||
|
- click/advance pagination and wait for signature change
|
||||||
|
- merge pages by `authorId` without overwriting non-empty fields
|
||||||
|
- return a final `MarketRecord[]`
|
||||||
|
|
||||||
|
Keep `dom-sync.ts` focused on DOM reads:
|
||||||
|
- page signature
|
||||||
|
- next-page element lookup
|
||||||
|
- next-page enabled check
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `npm test -- tests/market-dom-sync.test.ts`
|
||||||
|
- `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts
|
||||||
|
git commit -m "feat: add multi-page export controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Wire Controller Into Market Entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/content/market/index.ts`
|
||||||
|
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||||
|
- Test: `tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests that assert:
|
||||||
|
- `导出CSV` uses the selected range
|
||||||
|
- progress text updates while exporting
|
||||||
|
- controls are re-enabled on success and failure
|
||||||
|
- invalid custom input blocks export without calling CSV builder
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
Expected: FAIL because `market/index.ts` still always exports current-page records.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal implementation**
|
||||||
|
|
||||||
|
Update `market/index.ts` to:
|
||||||
|
- parse toolbar export range
|
||||||
|
- keep current-page export for `当前页`
|
||||||
|
- delegate batch modes to `export-range-controller.ts`
|
||||||
|
- update toolbar running/error/progress state
|
||||||
|
- preserve existing current-page lazy-load harvest path
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||||
|
git commit -m "feat: wire export range selection into market export"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Verify End-To-End
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run targeted regression tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `npm test -- tests/market-content-entry.test.ts`
|
||||||
|
- `npm test -- tests/market-dom-sync.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS with all test files green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS and emit updated `dist/` bundles.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mark the plan status**
|
||||||
|
|
||||||
|
Update this document’s checkboxes to reflect completed steps.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/plans/2026-04-21-market-export-range.md
|
||||||
|
git commit -m "docs: record export range implementation progress"
|
||||||
|
```
|
||||||
257
docs/superpowers/specs/2026-04-21-market-export-range-design.md
Normal file
257
docs/superpowers/specs/2026-04-21-market-export-range-design.md
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
# 星图市场导出范围设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前插件的 `导出CSV` 只导出当前页数据,并在星图默认列后追加两列:
|
||||||
|
|
||||||
|
- `单视频看后搜率`
|
||||||
|
- `个人视频看后搜率`
|
||||||
|
|
||||||
|
现阶段用户希望把导出范围扩展为可配置页数,而不是固定当前页。同时需要保留当前默认列导出逻辑,避免重新引入之前“全量扫描导致页面闪动、卡顿、排序异常”的问题。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 支持导出范围配置,默认导出前 `5` 页。
|
||||||
|
- 支持以下范围选项:
|
||||||
|
- `当前页`
|
||||||
|
- `前5页`
|
||||||
|
- `前10页`
|
||||||
|
- `全部`
|
||||||
|
- `自定义`
|
||||||
|
- 保留当前 CSV 规则:
|
||||||
|
- 先导出星图页面默认列
|
||||||
|
- 末尾追加两列插件字段
|
||||||
|
- 批量导出时真实翻页采集当前筛选结果页,不改变当前页面筛选条件来源。
|
||||||
|
- 保持当前“筛选/排序只作用当前页”的行为,不重新绑定多页扫描逻辑。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改为直接调用星图列表接口拉多页数据。
|
||||||
|
- 不让插件筛选或排序自动跨页生效。
|
||||||
|
- 不处理“导出后自动回到最初分页位置”的复杂恢复,首版只要求滚动位置恢复到单页采集前。
|
||||||
|
|
||||||
|
## 交互设计
|
||||||
|
|
||||||
|
在插件工具栏中保留一个 `导出CSV` 按钮,同时新增导出范围控件。
|
||||||
|
|
||||||
|
### 范围控件
|
||||||
|
|
||||||
|
- 默认值:`前5页`
|
||||||
|
- 可选项:
|
||||||
|
- `当前页`
|
||||||
|
- `前5页`
|
||||||
|
- `前10页`
|
||||||
|
- `全部`
|
||||||
|
- `自定义`
|
||||||
|
- 选择 `自定义` 时,展示页数输入框
|
||||||
|
- 仅接受正整数
|
||||||
|
- 无效值时禁止导出并提示错误
|
||||||
|
|
||||||
|
### 任务状态
|
||||||
|
|
||||||
|
- 导出开始后:
|
||||||
|
- `导出CSV` 按钮禁用
|
||||||
|
- 筛选按钮、排序按钮、范围控件、自定义输入框一并禁用
|
||||||
|
- 按钮文案根据任务模式更新:
|
||||||
|
- 当前页:`导出中...`
|
||||||
|
- 前 N 页:`导出中 3/5 页...`
|
||||||
|
- 全部:`导出中 第3页...`
|
||||||
|
- 全部导出时额外显示轻提示:
|
||||||
|
- `全部导出可能耗时较长,页面会自动翻页。`
|
||||||
|
|
||||||
|
### 失败反馈
|
||||||
|
|
||||||
|
- 任一页采集失败时直接终止
|
||||||
|
- 通过按钮附近状态文本或 `alert` 提示:
|
||||||
|
- `第 N 页导出失败,请稍后重试`
|
||||||
|
- 失败时不下载残缺 CSV
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
### 当前页导出
|
||||||
|
|
||||||
|
当前页模式沿用现有逻辑:
|
||||||
|
|
||||||
|
1. 等待当前页表格可读取
|
||||||
|
2. 自动滚动当前页一次,补齐懒加载字段
|
||||||
|
3. 读取当前页星图默认列
|
||||||
|
4. 合并插件两列
|
||||||
|
5. 生成 CSV 并下载
|
||||||
|
|
||||||
|
### 多页导出
|
||||||
|
|
||||||
|
多页模式仅在导出任务中启用,不影响平时筛选和排序。
|
||||||
|
|
||||||
|
1. 读取导出范围配置
|
||||||
|
2. 初始化批量任务状态与结果聚合器
|
||||||
|
3. 对当前页执行:
|
||||||
|
- 等待表格稳定
|
||||||
|
- 自动滚动当前页以采全默认列
|
||||||
|
- 读取当前页记录
|
||||||
|
- 以 `authorId` 合并入结果集
|
||||||
|
4. 判断是否达到目标页数
|
||||||
|
5. 若未完成,则点击下一页
|
||||||
|
6. 等待“页切换完成”信号
|
||||||
|
7. 重复步骤 3 到 6
|
||||||
|
8. 汇总所有记录,生成单个 CSV 下载
|
||||||
|
|
||||||
|
### 全部导出停止条件
|
||||||
|
|
||||||
|
`全部` 模式从当前页开始向后翻页,直到任一条件成立:
|
||||||
|
|
||||||
|
- 下一页按钮不可点击
|
||||||
|
- 当前页签名不再变化
|
||||||
|
- 页面无法继续翻页并达到超时阈值
|
||||||
|
|
||||||
|
## 页切换判定
|
||||||
|
|
||||||
|
批量导出不能只依赖固定 `setTimeout`。翻页完成需要同时满足至少一个强信号:
|
||||||
|
|
||||||
|
- 当前页作者 ID 集合发生变化
|
||||||
|
- 当前页首行作者 ID 变化
|
||||||
|
- 当前页活动分页按钮变化
|
||||||
|
- 下一页按钮状态变化
|
||||||
|
|
||||||
|
为避免误判,翻页流程需要:
|
||||||
|
|
||||||
|
1. 记录翻页前页签名
|
||||||
|
2. 点击下一页
|
||||||
|
3. 轮询直到签名变化或超时
|
||||||
|
4. 签名变化后再执行一次当前页表格稳定等待
|
||||||
|
|
||||||
|
## 当前页采集策略
|
||||||
|
|
||||||
|
当前页导出已知存在懒加载问题:页面初加载时后半段行的默认列为空,滚动后才补齐。
|
||||||
|
|
||||||
|
因此无论当前页导出还是多页导出,每一页都必须执行:
|
||||||
|
|
||||||
|
1. 初次读取当前页快照并写入 store
|
||||||
|
2. 查找当前市场列表滚动容器
|
||||||
|
3. 逐步向下滚动直到该页底部
|
||||||
|
4. 每次滚动后等待 DOM 稳定,再采集一轮
|
||||||
|
5. 恢复滚动位置并再采集一轮
|
||||||
|
|
||||||
|
这一步只针对“当前已打开的页”,不跨页扫描。
|
||||||
|
|
||||||
|
## 数据合并规则
|
||||||
|
|
||||||
|
### 去重键
|
||||||
|
|
||||||
|
- 使用 `authorId` 去重
|
||||||
|
|
||||||
|
### 字段合并
|
||||||
|
|
||||||
|
- 新页记录有值时,补充到已有记录
|
||||||
|
- 新页记录为空时,不覆盖已有非空值
|
||||||
|
- 插件两列沿用当前已有的合并规则
|
||||||
|
|
||||||
|
### CSV 表头
|
||||||
|
|
||||||
|
- 先保留星图默认列顺序
|
||||||
|
- 再追加:
|
||||||
|
- `单视频看后搜率`
|
||||||
|
- `个人视频看后搜率`
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 单页失败
|
||||||
|
|
||||||
|
以下情况视为单页失败:
|
||||||
|
|
||||||
|
- 找不到表格根节点
|
||||||
|
- 找不到下一页按钮但目标尚未完成
|
||||||
|
- 翻页后页签名未变化且超时
|
||||||
|
- 当前页采集过程中出现异常
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 立即终止任务
|
||||||
|
- 恢复工具栏控件状态
|
||||||
|
- 显示页级错误信息
|
||||||
|
- 不触发下载
|
||||||
|
|
||||||
|
### 用户干预
|
||||||
|
|
||||||
|
首版不额外提供取消按钮,但应防御以下情况:
|
||||||
|
|
||||||
|
- 用户手动切换筛选
|
||||||
|
- 用户手动翻页
|
||||||
|
- 用户再次点击导出
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 导出期间禁用插件控件
|
||||||
|
- 对关键节点重新校验表格和页签名
|
||||||
|
- 如环境已被破坏,终止任务并报错
|
||||||
|
|
||||||
|
## 实现建议
|
||||||
|
|
||||||
|
建议按以下边界组织实现:
|
||||||
|
|
||||||
|
### `plugin-toolbar`
|
||||||
|
|
||||||
|
- 新增导出范围选择器
|
||||||
|
- 新增自定义页数输入框
|
||||||
|
- 新增导出过程中的状态展示
|
||||||
|
|
||||||
|
### `market/index`
|
||||||
|
|
||||||
|
- 保留当前页导出逻辑作为单页模式
|
||||||
|
- 新增批量导出入口
|
||||||
|
- 管理任务状态、按钮禁用、进度回写
|
||||||
|
|
||||||
|
### 新建批量导出控制器
|
||||||
|
|
||||||
|
建议新增类似 `export-range-controller.ts` 的模块,职责包括:
|
||||||
|
|
||||||
|
- 读取当前页记录
|
||||||
|
- 控制翻页
|
||||||
|
- 等待页切换
|
||||||
|
- 聚合多页数据
|
||||||
|
- 统一返回最终 `MarketRecord[]`
|
||||||
|
|
||||||
|
这样可以避免把 `market/index.ts` 继续堆大。
|
||||||
|
|
||||||
|
### `dom-sync` 或新辅助模块
|
||||||
|
|
||||||
|
- 提供读取当前页签名
|
||||||
|
- 提供定位下一页按钮
|
||||||
|
- 提供判断下一页是否可点击
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
- 默认导出范围为前 5 页
|
||||||
|
- 选择 `当前页` 时只导出一页
|
||||||
|
- 选择 `前10页` 时最多导出 10 页
|
||||||
|
- `自定义` 输入非法值时不启动导出
|
||||||
|
- `全部` 模式在没有下一页时停止
|
||||||
|
- 多页合并时按 `authorId` 去重
|
||||||
|
- 空字段不会覆盖已有非空字段
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
- 批量导出时会依次点击下一页
|
||||||
|
- 翻页后等待新页签名再采集
|
||||||
|
- 每一页在导出前都会执行滚动采集
|
||||||
|
- 中途某页失败时不生成 CSV
|
||||||
|
- 导出期间工具栏控件被禁用
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 星图分页 DOM 结构可能不稳定,下一页按钮定位需要做降级兼容。
|
||||||
|
- 全部导出在高页数场景下耗时会较长。
|
||||||
|
- 页面中途刷新或筛选重算可能打断批量任务。
|
||||||
|
|
||||||
|
## 推荐实施顺序
|
||||||
|
|
||||||
|
1. 工具栏新增范围选择与自定义输入
|
||||||
|
2. 抽出批量导出控制器
|
||||||
|
3. 实现前 N 页导出
|
||||||
|
4. 在前 N 页能力稳定后开放 `全部`
|
||||||
|
5. 补齐全部相关失败与超时测试
|
||||||
|
|
||||||
|
## 决策结论
|
||||||
|
|
||||||
|
采用“一个导出按钮 + 范围选择器”的方案,默认导出前 `5` 页,支持 `全部` 和 `自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "market-plugin-impl",
|
"name": "market-plugin-impl",
|
||||||
"version": "0.0.0",
|
"version": "0.2.0421.2",
|
||||||
"description": "Bootstrap for the Xingtu market Chrome MV3 extension.",
|
"description": "Bootstrap for the Xingtu market Chrome MV3 extension.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const distDir = path.join(projectRoot, "dist");
|
|||||||
|
|
||||||
await rm(distDir, { recursive: true, force: true });
|
await rm(distDir, { recursive: true, force: true });
|
||||||
await mkdir(path.join(distDir, "content"), { recursive: true });
|
await mkdir(path.join(distDir, "content"), { recursive: true });
|
||||||
|
await mkdir(path.join(distDir, "background"), { recursive: true });
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
entry: {
|
entry: {
|
||||||
@ -32,6 +33,23 @@ await build({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entry: {
|
||||||
|
index: path.join(projectRoot, "src/background/index.ts")
|
||||||
|
},
|
||||||
|
format: ["iife"],
|
||||||
|
platform: "browser",
|
||||||
|
target: "chrome114",
|
||||||
|
outDir: path.join(distDir, "background"),
|
||||||
|
clean: false,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: false,
|
||||||
|
minify: false,
|
||||||
|
outExtension() {
|
||||||
|
return { js: ".js" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await cp(
|
await cp(
|
||||||
path.join(projectRoot, "src/manifest.json"),
|
path.join(projectRoot, "src/manifest.json"),
|
||||||
path.join(distDir, "manifest.json")
|
path.join(distDir, "manifest.json")
|
||||||
|
|||||||
95
src/background/index.ts
Normal file
95
src/background/index.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
interface ChromeDownloadsLike {
|
||||||
|
download(
|
||||||
|
options: {
|
||||||
|
filename: string;
|
||||||
|
saveAs?: boolean;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
callback?: () => void
|
||||||
|
): Promise<unknown> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChromeRuntimeLike {
|
||||||
|
onMessage?: {
|
||||||
|
addListener(
|
||||||
|
listener: (
|
||||||
|
message: unknown,
|
||||||
|
sender: unknown,
|
||||||
|
sendResponse: (response: unknown) => void
|
||||||
|
) => boolean | void
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChromeLike {
|
||||||
|
downloads?: ChromeDownloadsLike;
|
||||||
|
runtime?: ChromeRuntimeLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadMarketCsvMessage = {
|
||||||
|
csv: string;
|
||||||
|
filename: string;
|
||||||
|
type: "download-market-csv";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerBackgroundMessageHandler(
|
||||||
|
chromeLike: ChromeLike = (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: ChromeLike;
|
||||||
|
}
|
||||||
|
).chrome ?? {}
|
||||||
|
): void {
|
||||||
|
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (!isDownloadMarketCsvMessage(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void triggerCsvDownload(chromeLike, message)
|
||||||
|
.then(() => {
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
ok: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerCsvDownload(
|
||||||
|
chromeLike: ChromeLike,
|
||||||
|
message: DownloadMarketCsvMessage
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chromeLike.downloads?.download) {
|
||||||
|
throw new Error("chrome.downloads.download is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
|
||||||
|
await Promise.resolve(
|
||||||
|
chromeLike.downloads.download({
|
||||||
|
filename: message.filename,
|
||||||
|
saveAs: false,
|
||||||
|
url: csvUrl
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDownloadMarketCsvMessage(
|
||||||
|
message: unknown
|
||||||
|
): message is DownloadMarketCsvMessage {
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = message as Partial<DownloadMarketCsvMessage>;
|
||||||
|
return (
|
||||||
|
candidate.type === "download-market-csv" &&
|
||||||
|
typeof candidate.csv === "string" &&
|
||||||
|
typeof candidate.filename === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBackgroundMessageHandler();
|
||||||
@ -6,8 +6,11 @@ import {
|
|||||||
interface ChromeRuntimeLike {
|
interface ChromeRuntimeLike {
|
||||||
getURL?: (path: string) => string;
|
getURL?: (path: string) => string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
sendMessage?: (message: unknown) => void | Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv";
|
||||||
|
|
||||||
interface BootContentScriptOptions {
|
interface BootContentScriptOptions {
|
||||||
createMarketController?: (
|
createMarketController?: (
|
||||||
options: CreateMarketControllerOptions
|
options: CreateMarketControllerOptions
|
||||||
@ -32,6 +35,13 @@ export async function bootContentScript(
|
|||||||
|
|
||||||
return controllerFactory({
|
return controllerFactory({
|
||||||
document: currentDocument,
|
document: currentDocument,
|
||||||
|
onCsvReady: (csv: string) => {
|
||||||
|
if (requestCsvDownload(csv)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadCsv(currentDocument, currentWindow, csv);
|
||||||
|
},
|
||||||
window: currentWindow
|
window: currentWindow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -72,6 +82,43 @@ function bootstrapContentScript() {
|
|||||||
|
|
||||||
bootstrapContentScript();
|
bootstrapContentScript();
|
||||||
|
|
||||||
|
function requestCsvDownload(csv: string): boolean {
|
||||||
|
const runtime = (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: { runtime?: ChromeRuntimeLike };
|
||||||
|
}
|
||||||
|
).chrome?.runtime;
|
||||||
|
|
||||||
|
if (!runtime?.id || typeof runtime.sendMessage !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.sendMessage({
|
||||||
|
csv,
|
||||||
|
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||||
|
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestampForFilename(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
function installMarketPageBridge(document: Document) {
|
function installMarketPageBridge(document: Document) {
|
||||||
if (
|
if (
|
||||||
document.documentElement.querySelector(
|
document.documentElement.querySelector(
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
|||||||
import { escapeCsvCell } from "../../shared/csv";
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
const CSV_COLUMNS = [
|
type CsvColumn = {
|
||||||
|
header: string;
|
||||||
|
readValue: (record: MarketRecord) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||||
{
|
{
|
||||||
header: "达人ID",
|
header: "达人ID",
|
||||||
readValue: (record: MarketRecord) => record.authorId
|
readValue: (record: MarketRecord) => record.authorId
|
||||||
@ -18,7 +23,10 @@ const CSV_COLUMNS = [
|
|||||||
{
|
{
|
||||||
header: "21-60s报价",
|
header: "21-60s报价",
|
||||||
readValue: (record: MarketRecord) => record.price21To60s ?? ""
|
readValue: (record: MarketRecord) => record.price21To60s ?? ""
|
||||||
},
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const RATE_COLUMNS: CsvColumn[] = [
|
||||||
{
|
{
|
||||||
header: "单视频看后搜率",
|
header: "单视频看后搜率",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
@ -32,18 +40,41 @@ const CSV_COLUMNS = [
|
|||||||
record.rates?.personalVideoAfterSearchRate
|
record.rates?.personalVideoAfterSearchRate
|
||||||
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
|
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
|
||||||
: ""
|
: ""
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "插件数据状态",
|
|
||||||
readValue: (record: MarketRecord) => record.status
|
|
||||||
}
|
}
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||||
const headerLine = CSV_COLUMNS.map((column) => column.header).join(",");
|
const baseColumns = buildBaseColumns(records);
|
||||||
|
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
|
||||||
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = records.map((record) =>
|
const rowLines = records.map((record) =>
|
||||||
CSV_COLUMNS.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||||
);
|
);
|
||||||
|
|
||||||
return [headerLine, ...rowLines].join("\n");
|
return [headerLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
|
const orderedHeaders: string[] = [];
|
||||||
|
const seenHeaders = new Set<string>();
|
||||||
|
|
||||||
|
records.forEach((record) => {
|
||||||
|
Object.keys(record.exportFields ?? {}).forEach((header) => {
|
||||||
|
if (seenHeaders.has(header)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenHeaders.add(header);
|
||||||
|
orderedHeaders.push(header);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orderedHeaders.length === 0) {
|
||||||
|
return FALLBACK_BASE_COLUMNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedHeaders.map((header) => ({
|
||||||
|
header,
|
||||||
|
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type RowOrderTarget = {
|
|||||||
export interface MarketRowDom {
|
export interface MarketRowDom {
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
exportFields?: Record<string, string>;
|
||||||
hasDirectRatesSource?: boolean;
|
hasDirectRatesSource?: boolean;
|
||||||
personalCell: HTMLElement;
|
personalCell: HTMLElement;
|
||||||
price21To60s?: string;
|
price21To60s?: string;
|
||||||
@ -38,6 +39,68 @@ export function syncMarketTable(root: ParentNode): MarketTableDom | null {
|
|||||||
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
|
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readMarketPageSignature(root: ParentNode): string {
|
||||||
|
const document = getOwnerDocument(root);
|
||||||
|
const explicitPageIndex =
|
||||||
|
document?.documentElement.getAttribute("data-test-page-index") ?? "";
|
||||||
|
const activePageIndex =
|
||||||
|
document
|
||||||
|
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
|
||||||
|
?.textContent?.trim() ?? "";
|
||||||
|
const table = syncMarketTable(root);
|
||||||
|
const authorIds =
|
||||||
|
table?.rows
|
||||||
|
.map((row) => row.authorId)
|
||||||
|
.filter((authorId) => Boolean(authorId))
|
||||||
|
.join("|") ?? "";
|
||||||
|
|
||||||
|
return `${explicitPageIndex || activePageIndex}::${authorIds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNextPageControl(root: ParentNode): HTMLElement | null {
|
||||||
|
const document = getOwnerDocument(root);
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitControl = document.querySelector('[data-testid="next-page"]');
|
||||||
|
if (explicitControl instanceof document.defaultView!.HTMLElement) {
|
||||||
|
return explicitControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationNextControl = document.querySelector(
|
||||||
|
".el-pagination .btn-next, .xt-pagination .btn-next"
|
||||||
|
);
|
||||||
|
if (paginationNextControl instanceof document.defaultView!.HTMLElement) {
|
||||||
|
return paginationNextControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = Array.from(
|
||||||
|
document.querySelectorAll("button, a, [role='button']")
|
||||||
|
).filter(
|
||||||
|
(element): element is HTMLElement =>
|
||||||
|
element instanceof document.defaultView!.HTMLElement
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
candidates.find((element) =>
|
||||||
|
/下一页|next/i.test(normalizeExportCellText(element.textContent))
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPageControlDisabled(control: HTMLElement | null): boolean {
|
||||||
|
if (!control) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control instanceof HTMLButtonElement) {
|
||||||
|
return control.disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return control.getAttribute("aria-disabled") === "true";
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMarketRowState(
|
export function renderMarketRowState(
|
||||||
rowDom: MarketRowDom,
|
rowDom: MarketRowDom,
|
||||||
record: MarketRecord
|
record: MarketRecord
|
||||||
@ -109,6 +172,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
|||||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
||||||
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||||
|
|
||||||
|
const headerLabelsByField = readSyntheticHeaderLabels(header);
|
||||||
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
|
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
|
||||||
(rowElement) => {
|
(rowElement) => {
|
||||||
const row = rowElement as HTMLElement;
|
const row = rowElement as HTMLElement;
|
||||||
@ -120,6 +184,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
|||||||
authorName:
|
authorName:
|
||||||
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||||
"",
|
"",
|
||||||
|
exportFields: readSyntheticExportFields(row, headerLabelsByField),
|
||||||
hasDirectRatesSource: false,
|
hasDirectRatesSource: false,
|
||||||
orderTargets: [
|
orderTargets: [
|
||||||
{
|
{
|
||||||
@ -228,6 +293,11 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
? getDirectContentColumns(section)
|
? getDirectContentColumns(section)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
const allHeaderCells = Array.from(headerSection.children).flatMap((section) =>
|
||||||
|
section instanceof root.ownerDocument.defaultView!.HTMLElement
|
||||||
|
? getDirectHeaderCells(section)
|
||||||
|
: []
|
||||||
|
);
|
||||||
const authorCells = getDirectContentCells(authorColumn);
|
const authorCells = getDirectContentCells(authorColumn);
|
||||||
const singleCells = getDirectContentCells(singleColumn);
|
const singleCells = getDirectContentCells(singleColumn);
|
||||||
const personalCells = getDirectContentCells(personalColumn);
|
const personalCells = getDirectContentCells(personalColumn);
|
||||||
@ -263,6 +333,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
{
|
{
|
||||||
authorId,
|
authorId,
|
||||||
authorName,
|
authorName,
|
||||||
|
exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells),
|
||||||
hasDirectRatesSource:
|
hasDirectRatesSource:
|
||||||
vueMarketRow?.hasDirectRatesSource ??
|
vueMarketRow?.hasDirectRatesSource ??
|
||||||
serializedMarketRow?.hasDirectRatesSource ??
|
serializedMarketRow?.hasDirectRatesSource ??
|
||||||
@ -420,6 +491,64 @@ function getOwnerDocument(root: ParentNode): Document | null {
|
|||||||
return root instanceof Document ? root : null;
|
return root instanceof Document ? root : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
|
||||||
|
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
|
||||||
|
Record<string, string>
|
||||||
|
>((labels, cell) => {
|
||||||
|
if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) {
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = cell.dataset.marketHeaderCell;
|
||||||
|
if (!field) {
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[field] = normalizeExportCellText(cell.textContent);
|
||||||
|
return labels;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSyntheticExportFields(
|
||||||
|
row: HTMLElement,
|
||||||
|
headerLabelsByField: Record<string, string>
|
||||||
|
): Record<string, string> {
|
||||||
|
const exportFields: Record<string, string> = {};
|
||||||
|
for (const cell of row.querySelectorAll("[data-market-field]")) {
|
||||||
|
if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = cell.dataset.marketField;
|
||||||
|
const headerLabel = field ? headerLabelsByField[field] : "";
|
||||||
|
if (!shouldExportColumn(headerLabel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExportFieldsForDivGridRow(
|
||||||
|
headerCells: HTMLElement[],
|
||||||
|
rowCells: HTMLElement[]
|
||||||
|
): Record<string, string> {
|
||||||
|
const exportFields: Record<string, string> = {};
|
||||||
|
|
||||||
|
rowCells.forEach((cell, index) => {
|
||||||
|
const headerLabel = normalizeExportCellText(headerCells[index]?.textContent);
|
||||||
|
if (!shouldExportColumn(headerLabel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
return exportFields;
|
||||||
|
}
|
||||||
|
|
||||||
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
|
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
|
||||||
let current = cell.previousElementSibling;
|
let current = cell.previousElementSibling;
|
||||||
while (current) {
|
while (current) {
|
||||||
@ -674,6 +803,19 @@ function normalizeMarketListRate(value: unknown): string | null {
|
|||||||
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExportCellText(value: string | null | undefined): string {
|
||||||
|
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldExportColumn(label: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
label &&
|
||||||
|
label !== ACTION_HEADER_TEXT &&
|
||||||
|
label !== "单视频看后搜率" &&
|
||||||
|
label !== "个人视频看后搜率"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function readRateCellText(value: string | undefined): string {
|
function readRateCellText(value: string | undefined): string {
|
||||||
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
|
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
|
||||||
}
|
}
|
||||||
|
|||||||
276
src/content/market/export-range-controller.ts
Normal file
276
src/content/market/export-range-controller.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
findNextPageControl,
|
||||||
|
isPageControlDisabled,
|
||||||
|
readMarketPageSignature
|
||||||
|
} from "./dom-sync";
|
||||||
|
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
|
||||||
|
|
||||||
|
interface ExportRangeControllerOptions {
|
||||||
|
document: Document;
|
||||||
|
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||||
|
prepareCurrentPageForExport(): Promise<void>;
|
||||||
|
readCurrentPageRecords(): MarketRecord[];
|
||||||
|
window: Window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExportRangeController(options: ExportRangeControllerOptions) {
|
||||||
|
return {
|
||||||
|
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
|
||||||
|
const mergedRecords = new Map<string, MarketRecord>();
|
||||||
|
let currentPage = 0;
|
||||||
|
let expectedMinimumRowCount: number | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
currentPage += 1;
|
||||||
|
options.onProgress?.({
|
||||||
|
currentPage,
|
||||||
|
totalPages: target.mode === "count" ? target.pageCount : undefined
|
||||||
|
});
|
||||||
|
const 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));
|
||||||
|
});
|
||||||
|
expectedMinimumRowCount = Math.max(
|
||||||
|
expectedMinimumRowCount ?? 0,
|
||||||
|
currentPageRecords.length
|
||||||
|
);
|
||||||
|
|
||||||
|
if (target.mode === "count" && currentPage >= target.pageCount) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSignature = readMarketPageSignature(options.document);
|
||||||
|
const nextPageControl = findNextPageControl(options.document);
|
||||||
|
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPageControl.click();
|
||||||
|
const pageChanged = await waitForPageChange(previousSignature);
|
||||||
|
if (!pageChanged) {
|
||||||
|
throw new Error(`第 ${currentPage + 1} 页导出失败,请稍后重试`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(mergedRecords.values());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function waitForPageChange(previousSignature: string): Promise<boolean> {
|
||||||
|
const previousPageState = parsePageSignature(previousSignature);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
options.window.setTimeout(resolve, 50);
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const nextSignature = readMarketPageSignature(options.document);
|
||||||
|
const nextPageState = parsePageSignature(nextSignature);
|
||||||
|
if (hasLoadedNextPage(previousPageState, nextPageState)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCurrentPageReady(
|
||||||
|
expectedMinimumRowCount: number | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
let stableAttemptCount = 0;
|
||||||
|
let lastReadyFingerprint = "";
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
options.window.setTimeout(resolve, 150);
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const pageState = readCurrentPageState();
|
||||||
|
if (!pageState.authorIds || pageState.rowCount <= 0) {
|
||||||
|
stableAttemptCount = 0;
|
||||||
|
lastReadyFingerprint = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof expectedMinimumRowCount === "number" &&
|
||||||
|
expectedMinimumRowCount > 0 &&
|
||||||
|
!pageState.isTerminalPage &&
|
||||||
|
pageState.rowCount < expectedMinimumRowCount
|
||||||
|
) {
|
||||||
|
stableAttemptCount = 0;
|
||||||
|
lastReadyFingerprint = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyFingerprint = [
|
||||||
|
pageState.pageToken,
|
||||||
|
pageState.authorIds,
|
||||||
|
String(pageState.rowCount),
|
||||||
|
pageState.isTerminalPage ? "terminal" : "paged"
|
||||||
|
].join("::");
|
||||||
|
if (readyFingerprint === lastReadyFingerprint) {
|
||||||
|
stableAttemptCount += 1;
|
||||||
|
} else {
|
||||||
|
lastReadyFingerprint = readyFingerprint;
|
||||||
|
stableAttemptCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stableAttemptCount >= 6) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentPageState(): {
|
||||||
|
authorIds: string;
|
||||||
|
isTerminalPage: boolean;
|
||||||
|
pageToken: string;
|
||||||
|
rowCount: number;
|
||||||
|
} {
|
||||||
|
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
|
||||||
|
const nextPageControl = findNextPageControl(options.document);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorIds: pageSignature.authorIds,
|
||||||
|
isTerminalPage: isPageControlDisabled(nextPageControl),
|
||||||
|
pageToken: pageSignature.pageToken,
|
||||||
|
rowCount: options.readCurrentPageRecords().length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePageSignature(signature: string): {
|
||||||
|
authorIds: string;
|
||||||
|
pageToken: string;
|
||||||
|
} {
|
||||||
|
const separatorIndex = signature.indexOf("::");
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return {
|
||||||
|
authorIds: "",
|
||||||
|
pageToken: signature.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorIds: signature.slice(separatorIndex + 2).trim(),
|
||||||
|
pageToken: signature.slice(0, separatorIndex).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLoadedNextPage(
|
||||||
|
previousPageState: {
|
||||||
|
authorIds: string;
|
||||||
|
pageToken: string;
|
||||||
|
},
|
||||||
|
nextPageState: {
|
||||||
|
authorIds: string;
|
||||||
|
pageToken: string;
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
if (!nextPageState.authorIds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPageState.pageToken || previousPageState.pageToken) {
|
||||||
|
return nextPageState.pageToken !== previousPageState.pageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextPageState.authorIds !== previousPageState.authorIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMarketRecord(
|
||||||
|
existingRecord: MarketRecord | undefined,
|
||||||
|
incomingRecord: MarketRecord
|
||||||
|
): MarketRecord {
|
||||||
|
if (!existingRecord) {
|
||||||
|
return {
|
||||||
|
...incomingRecord,
|
||||||
|
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
|
||||||
|
rates: mergeFieldMap(undefined, incomingRecord.rates)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existingRecord,
|
||||||
|
...incomingRecord,
|
||||||
|
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
|
||||||
|
exportFields: mergeFieldMap(
|
||||||
|
existingRecord.exportFields,
|
||||||
|
incomingRecord.exportFields
|
||||||
|
),
|
||||||
|
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
|
||||||
|
hasDirectRatesSource:
|
||||||
|
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
|
||||||
|
location: mergeStringValue(existingRecord.location, incomingRecord.location),
|
||||||
|
price21To60s: mergeStringValue(
|
||||||
|
existingRecord.price21To60s,
|
||||||
|
incomingRecord.price21To60s
|
||||||
|
),
|
||||||
|
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
|
||||||
|
status: mergeStatus(existingRecord.status, incomingRecord.status)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||||
|
current: T | undefined,
|
||||||
|
incoming: T | undefined
|
||||||
|
): T | undefined {
|
||||||
|
if (!current && !incoming) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...(current ?? {})
|
||||||
|
} as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||||
|
const currentValue = merged[key];
|
||||||
|
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStatus(
|
||||||
|
current: MarketRecordStatus,
|
||||||
|
incoming: MarketRecordStatus
|
||||||
|
): MarketRecordStatus {
|
||||||
|
const priority: Record<MarketRecordStatus, number> = {
|
||||||
|
failed: 1,
|
||||||
|
idle: 0,
|
||||||
|
loading: 2,
|
||||||
|
missing: -1,
|
||||||
|
success: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
return priority[incoming] >= priority[current] ? incoming : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStringValue(
|
||||||
|
current: string | undefined,
|
||||||
|
incoming: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (hasTextValue(incoming) || !hasTextValue(current)) {
|
||||||
|
return incoming ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTextValue(value: string | undefined): boolean {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
@ -7,24 +7,24 @@ import {
|
|||||||
type MarketRowDom
|
type MarketRowDom
|
||||||
} from "./dom-sync";
|
} from "./dom-sync";
|
||||||
import { applyFilterAndSort } from "./filter-sort-controller";
|
import { applyFilterAndSort } from "./filter-sort-controller";
|
||||||
import { createFullScanController } from "./full-scan-controller";
|
|
||||||
import { createMarketApiClient } from "./api-client";
|
import { createMarketApiClient } from "./api-client";
|
||||||
|
import { createExportRangeController } from "./export-range-controller";
|
||||||
import { ensurePluginToolbar } from "./plugin-toolbar";
|
import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||||
|
import {
|
||||||
|
readToolbarExportTarget,
|
||||||
|
setToolbarBusyState,
|
||||||
|
setToolbarExportStatus
|
||||||
|
} from "./plugin-toolbar";
|
||||||
import { createMarketResultStore } from "./result-store";
|
import { createMarketResultStore } from "./result-store";
|
||||||
import type {
|
import type {
|
||||||
MarketApiResult,
|
MarketApiResult,
|
||||||
MarketFilterState,
|
MarketFilterState,
|
||||||
|
MarketExportTarget,
|
||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketRowSnapshot,
|
MarketRowSnapshot,
|
||||||
MarketSortState
|
MarketSortState
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface FullScanControllerLike {
|
|
||||||
ensureScanForExport(): Promise<void>;
|
|
||||||
ensureScanForFilter(): Promise<void>;
|
|
||||||
ensureScanForSort(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutationObserverLike {
|
interface MutationObserverLike {
|
||||||
disconnect(): void;
|
disconnect(): void;
|
||||||
observe(target: Node, options?: MutationObserverInit): void;
|
observe(target: Node, options?: MutationObserverInit): void;
|
||||||
@ -33,7 +33,6 @@ interface MutationObserverLike {
|
|||||||
export interface CreateMarketControllerOptions {
|
export interface CreateMarketControllerOptions {
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
fullScanController?: FullScanControllerLike;
|
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
mutationObserverFactory?: (
|
mutationObserverFactory?: (
|
||||||
callback: MutationCallback
|
callback: MutationCallback
|
||||||
@ -52,22 +51,25 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const mutationObserverFactory =
|
const mutationObserverFactory =
|
||||||
options.mutationObserverFactory ??
|
options.mutationObserverFactory ??
|
||||||
((callback: MutationCallback) => new MutationObserver(callback));
|
((callback: MutationCallback) => new MutationObserver(callback));
|
||||||
|
const exportRangeController = createExportRangeController({
|
||||||
|
document: options.document,
|
||||||
|
onProgress: ({ currentPage, totalPages }) => {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
totalPages
|
||||||
|
? `导出中 ${currentPage}/${totalPages} 页...`
|
||||||
|
: `导出中 第${currentPage}页...`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
prepareCurrentPageForExport: prepareCurrentPageForExport,
|
||||||
|
readCurrentPageRecords: () => getVisibleOrderedRecords(),
|
||||||
|
window: options.window
|
||||||
|
});
|
||||||
let activeFilters: MarketFilterState = {};
|
let activeFilters: MarketFilterState = {};
|
||||||
let activeSort: MarketSortState | undefined;
|
let activeSort: MarketSortState | undefined;
|
||||||
let isSyncRunning = false;
|
let isSyncRunning = false;
|
||||||
let isSyncScheduled = false;
|
let isSyncScheduled = false;
|
||||||
let needsResync = false;
|
let needsResync = false;
|
||||||
|
|
||||||
const fullScanController =
|
|
||||||
options.fullScanController ??
|
|
||||||
createFullScanController({
|
|
||||||
goToNextPage: () => goToNextMarketPage(options.document, options.window),
|
|
||||||
hasNextPage: () => hasNextMarketPage(options.document),
|
|
||||||
loadAuthorMetrics,
|
|
||||||
readCurrentPageRows: () =>
|
|
||||||
readCurrentPageRows(options.document),
|
|
||||||
resultStore
|
|
||||||
});
|
|
||||||
const observer = mutationObserverFactory(() => {
|
const observer = mutationObserverFactory(() => {
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
});
|
});
|
||||||
@ -89,18 +91,32 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
toolbar.singleFilterInput.value
|
toolbar.singleFilterInput.value
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
await fullScanController.ensureScanForFilter();
|
|
||||||
applyCurrentView();
|
applyCurrentView();
|
||||||
},
|
},
|
||||||
onApplySort: async () => {
|
onApplySort: async () => {
|
||||||
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
||||||
await fullScanController.ensureScanForSort();
|
|
||||||
applyCurrentView();
|
applyCurrentView();
|
||||||
},
|
},
|
||||||
onExport: async () => {
|
onExport: async () => {
|
||||||
await fullScanController.ensureScanForExport();
|
const exportTarget = readToolbarExportTarget(toolbar);
|
||||||
const records = getVisibleOrderedRecords();
|
if (!exportTarget.target) {
|
||||||
options.onCsvReady?.(buildCsv(records));
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolbarBusyState(toolbar, true);
|
||||||
|
try {
|
||||||
|
const records = await exportRecords(exportTarget.target);
|
||||||
|
options.onCsvReady?.(buildCsv(records));
|
||||||
|
setToolbarExportStatus(toolbar, "");
|
||||||
|
} catch (error) {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
error instanceof Error ? error.message : "导出失败,请稍后重试"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setToolbarBusyState(toolbar, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -195,18 +211,213 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = getVisibleOrderedRecords();
|
const records = getVisibleOrderedRecords(table);
|
||||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||||
applyRowOrder(table, records.map((record) => record.authorId));
|
applyRowOrder(table, records.map((record) => record.authorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleOrderedRecords(): MarketRecord[] {
|
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
|
||||||
return applyFilterAndSort(resultStore.listRecords(), {
|
const currentPageRecords = readCurrentPageRecords(table);
|
||||||
|
|
||||||
|
return applyFilterAndSort(currentPageRecords, {
|
||||||
filters: activeFilters,
|
filters: activeFilters,
|
||||||
sort: activeSort
|
sort: activeSort
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
|
||||||
|
if (target.mode === "count" && target.pageCount <= 1) {
|
||||||
|
setToolbarExportStatus(toolbar, "导出中...");
|
||||||
|
await prepareCurrentPageForExport();
|
||||||
|
return getVisibleOrderedRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportRangeController.exportRecords(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
|
await runSyncCycle();
|
||||||
|
await harvestCurrentPageForExport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function harvestCurrentPageForExport(): Promise<void> {
|
||||||
|
await collectCurrentPageSnapshotsUntilSettled();
|
||||||
|
|
||||||
|
const table = syncMarketTable(options.document);
|
||||||
|
const scrollContainer = findCurrentPageScrollContainer(table);
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalScrollTop = scrollContainer.scrollTop;
|
||||||
|
const maxScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
scrollContainer.scrollHeight - scrollContainer.clientHeight
|
||||||
|
);
|
||||||
|
if (maxScrollTop <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = Math.max(scrollContainer.clientHeight, 240);
|
||||||
|
for (
|
||||||
|
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
|
||||||
|
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
|
||||||
|
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
|
||||||
|
) {
|
||||||
|
setScrollTop(scrollContainer, nextScrollTop);
|
||||||
|
await collectCurrentPageSnapshotsUntilSettled();
|
||||||
|
|
||||||
|
if (nextScrollTop === maxScrollTop) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollContainer.scrollTop !== originalScrollTop) {
|
||||||
|
setScrollTop(scrollContainer, originalScrollTop);
|
||||||
|
await collectCurrentPageSnapshotsUntilSettled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
|
||||||
|
if (!table) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return table.rows
|
||||||
|
.map((rowDom) => {
|
||||||
|
const rowSnapshot = readRowSnapshot(rowDom);
|
||||||
|
if (!rowSnapshot.authorId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
||||||
|
return {
|
||||||
|
...existingRecord,
|
||||||
|
...rowSnapshot,
|
||||||
|
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
|
||||||
|
exportFields: mergeFieldMap(
|
||||||
|
existingRecord?.exportFields,
|
||||||
|
rowSnapshot.exportFields
|
||||||
|
),
|
||||||
|
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
|
||||||
|
price21To60s: mergeStringValue(
|
||||||
|
existingRecord?.price21To60s,
|
||||||
|
rowSnapshot.price21To60s
|
||||||
|
),
|
||||||
|
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||||
|
status: existingRecord?.status ?? "idle"
|
||||||
|
} satisfies MarketRecord;
|
||||||
|
})
|
||||||
|
.filter((record): record is MarketRecord => record !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCurrentPageSnapshots(): void {
|
||||||
|
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
|
||||||
|
resultStore.upsertMarketRow(rowSnapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCurrentPageScrollContainer(
|
||||||
|
table: ReturnType<typeof syncMarketTable>
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (!table) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenElements = new Set<HTMLElement>();
|
||||||
|
const candidateRoots = table.rows
|
||||||
|
.map((rowDom) => rowDom.row)
|
||||||
|
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
|
||||||
|
|
||||||
|
for (const rootElement of candidateRoots) {
|
||||||
|
let currentElement = rootElement.parentElement;
|
||||||
|
while (currentElement) {
|
||||||
|
if (
|
||||||
|
!seenElements.has(currentElement) &&
|
||||||
|
isScrollableContainer(currentElement)
|
||||||
|
) {
|
||||||
|
return currentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenElements.add(currentElement);
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScrollableContainer(element: HTMLElement): boolean {
|
||||||
|
const computedStyle = options.window.getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
|
||||||
|
element.scrollHeight > element.clientHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDomSettled(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
options.window.setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
||||||
|
let previousFingerprint = "";
|
||||||
|
let stablePassCount = 0;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
await waitForDomSettled();
|
||||||
|
if (attempt > 0) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
options.window.setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
collectCurrentPageSnapshots();
|
||||||
|
const nextFingerprint = readVisibleRowHydrationFingerprint();
|
||||||
|
if (!nextFingerprint) {
|
||||||
|
stablePassCount = 0;
|
||||||
|
previousFingerprint = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextFingerprint === previousFingerprint) {
|
||||||
|
stablePassCount += 1;
|
||||||
|
} else {
|
||||||
|
previousFingerprint = nextFingerprint;
|
||||||
|
stablePassCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stablePassCount >= 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVisibleRowHydrationFingerprint(): string {
|
||||||
|
const table = syncMarketTable(options.document);
|
||||||
|
if (!table || table.rows.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return table.rows
|
||||||
|
.map((rowDom) => {
|
||||||
|
const rowSnapshot = readRowSnapshot(rowDom);
|
||||||
|
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||||
|
(value) => typeof value === "string" && value.trim().length > 0
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
rowSnapshot.authorId,
|
||||||
|
populatedFieldCount,
|
||||||
|
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
|
||||||
|
].join(":");
|
||||||
|
})
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleSync(): void {
|
function scheduleSync(): void {
|
||||||
if (isSyncRunning) {
|
if (isSyncRunning) {
|
||||||
needsResync = true;
|
needsResync = true;
|
||||||
@ -245,6 +456,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setScrollTop(element: HTMLElement, top: number): void {
|
||||||
|
element.scrollTop = top;
|
||||||
|
element.dispatchEvent(new Event("scroll"));
|
||||||
|
}
|
||||||
|
|
||||||
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
|
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
|
||||||
const table = syncMarketTable(document);
|
const table = syncMarketTable(document);
|
||||||
if (!table) {
|
if (!table) {
|
||||||
@ -260,6 +476,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
|||||||
return {
|
return {
|
||||||
authorId: rowDom.authorId,
|
authorId: rowDom.authorId,
|
||||||
authorName: rowDom.authorName,
|
authorName: rowDom.authorName,
|
||||||
|
exportFields: rowDom.exportFields,
|
||||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||||
price21To60s: rowDom.price21To60s,
|
price21To60s: rowDom.price21To60s,
|
||||||
rates: rowDom.rates
|
rates: rowDom.rates
|
||||||
@ -289,79 +506,39 @@ function readSortState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNextMarketPage(document: Document): boolean {
|
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||||
const nextButton = findNextPageButton(document);
|
current: T | undefined,
|
||||||
return Boolean(nextButton && !isDisabled(nextButton));
|
incoming: T | undefined
|
||||||
}
|
): T | undefined {
|
||||||
|
if (!current && !incoming) {
|
||||||
async function goToNextMarketPage(
|
return undefined;
|
||||||
document: Document,
|
|
||||||
window: Window
|
|
||||||
): Promise<boolean> {
|
|
||||||
const nextButton = findNextPageButton(document);
|
|
||||||
if (!nextButton || isDisabled(nextButton)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSignature = getCurrentPageSignature(document);
|
const merged = {
|
||||||
nextButton.click();
|
...(current ?? {})
|
||||||
|
} as Record<string, string | undefined>;
|
||||||
|
|
||||||
return waitForPageSignatureChange(document, window, previousSignature);
|
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||||
}
|
const currentValue = merged[key];
|
||||||
|
if (hasTextValue(value) || !hasTextValue(currentValue)) {
|
||||||
function findNextPageButton(document: Document): HTMLElement | null {
|
merged[key] = value;
|
||||||
const selectorMatch = document.querySelector(
|
}
|
||||||
'[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]'
|
|
||||||
);
|
|
||||||
if (selectorMatch instanceof document.defaultView!.HTMLElement) {
|
|
||||||
return selectorMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(document.querySelectorAll("button, a, div, span")).find(
|
|
||||||
(element): element is HTMLElement =>
|
|
||||||
element instanceof document.defaultView!.HTMLElement &&
|
|
||||||
element.textContent?.trim() === "下一页"
|
|
||||||
) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDisabled(element: HTMLElement): boolean {
|
|
||||||
return (
|
|
||||||
"disabled" in element &&
|
|
||||||
Boolean((element as HTMLButtonElement).disabled) ||
|
|
||||||
element.getAttribute("aria-disabled") === "true" ||
|
|
||||||
/disabled|is-disabled/.test(element.className)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentPageSignature(document: Document): string {
|
|
||||||
return readCurrentPageRows(document)
|
|
||||||
.map((row) => row.authorId)
|
|
||||||
.join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPageSignatureChange(
|
|
||||||
document: Document,
|
|
||||||
window: Window,
|
|
||||||
previousSignature: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
const currentSignature = getCurrentPageSignature(document);
|
|
||||||
if (currentSignature && currentSignature !== previousSignature) {
|
|
||||||
resolve(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - startedAt >= 5000) {
|
|
||||||
resolve(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setTimeout(check, 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
check();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return merged as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStringValue(
|
||||||
|
current: string | undefined,
|
||||||
|
incoming: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (hasTextValue(incoming) || !hasTextValue(current)) {
|
||||||
|
return incoming ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTextValue(value: string | undefined): boolean {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { MarketExportScope, MarketExportTarget } from "./types";
|
||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onApplyFilter(): Promise<void> | void;
|
onApplyFilter(): Promise<void> | void;
|
||||||
onApplySort(): Promise<void> | void;
|
onApplySort(): Promise<void> | void;
|
||||||
@ -6,6 +8,9 @@ export interface PluginToolbarHandlers {
|
|||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
|
exportRangeSelect: HTMLSelectElement;
|
||||||
|
exportStatusText: HTMLElement;
|
||||||
filterApplyButton: HTMLButtonElement;
|
filterApplyButton: HTMLButtonElement;
|
||||||
personalFilterInput: HTMLInputElement;
|
personalFilterInput: HTMLInputElement;
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
@ -65,6 +70,25 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.dataset.pluginExport = "button";
|
exportButton.dataset.pluginExport = "button";
|
||||||
exportButton.textContent = "导出CSV";
|
exportButton.textContent = "导出CSV";
|
||||||
|
|
||||||
|
const exportRangeSelect = document.createElement("select");
|
||||||
|
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||||
|
appendOption(exportRangeSelect, "current", "当前页");
|
||||||
|
appendOption(exportRangeSelect, "first-5", "前5页");
|
||||||
|
appendOption(exportRangeSelect, "first-10", "前10页");
|
||||||
|
appendOption(exportRangeSelect, "all", "全部");
|
||||||
|
appendOption(exportRangeSelect, "custom", "自定义");
|
||||||
|
exportRangeSelect.value = "first-5";
|
||||||
|
|
||||||
|
const exportCustomPagesInput = document.createElement("input");
|
||||||
|
exportCustomPagesInput.type = "number";
|
||||||
|
exportCustomPagesInput.min = "1";
|
||||||
|
exportCustomPagesInput.step = "1";
|
||||||
|
exportCustomPagesInput.hidden = true;
|
||||||
|
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||||
|
|
||||||
|
const exportStatusText = document.createElement("span");
|
||||||
|
exportStatusText.dataset.pluginExportStatus = "text";
|
||||||
|
|
||||||
root.append(
|
root.append(
|
||||||
singleFilterInput,
|
singleFilterInput,
|
||||||
personalFilterInput,
|
personalFilterInput,
|
||||||
@ -72,8 +96,11 @@ export function ensurePluginToolbar(
|
|||||||
sortFieldSelect,
|
sortFieldSelect,
|
||||||
sortDirectionSelect,
|
sortDirectionSelect,
|
||||||
sortApplyButton,
|
sortApplyButton,
|
||||||
|
exportRangeSelect,
|
||||||
|
exportCustomPagesInput,
|
||||||
exportButton
|
exportButton
|
||||||
);
|
);
|
||||||
|
root.append(exportStatusText);
|
||||||
document.body.prepend(root);
|
document.body.prepend(root);
|
||||||
|
|
||||||
filterApplyButton.addEventListener("click", () => {
|
filterApplyButton.addEventListener("click", () => {
|
||||||
@ -85,9 +112,27 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.addEventListener("click", () => {
|
exportButton.addEventListener("click", () => {
|
||||||
void handlers.onExport();
|
void handlers.onExport();
|
||||||
});
|
});
|
||||||
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
|
syncCustomPagesInputVisibility({
|
||||||
|
exportButton,
|
||||||
|
exportCustomPagesInput,
|
||||||
|
exportRangeSelect,
|
||||||
|
exportStatusText,
|
||||||
|
filterApplyButton,
|
||||||
|
personalFilterInput,
|
||||||
|
root,
|
||||||
|
singleFilterInput,
|
||||||
|
sortApplyButton,
|
||||||
|
sortDirectionSelect,
|
||||||
|
sortFieldSelect
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const toolbarDom = {
|
||||||
exportButton,
|
exportButton,
|
||||||
|
exportCustomPagesInput,
|
||||||
|
exportRangeSelect,
|
||||||
|
exportStatusText,
|
||||||
filterApplyButton,
|
filterApplyButton,
|
||||||
personalFilterInput,
|
personalFilterInput,
|
||||||
root,
|
root,
|
||||||
@ -95,7 +140,10 @@ export function ensurePluginToolbar(
|
|||||||
sortApplyButton,
|
sortApplyButton,
|
||||||
sortDirectionSelect,
|
sortDirectionSelect,
|
||||||
sortFieldSelect
|
sortFieldSelect
|
||||||
};
|
} satisfies PluginToolbarDom;
|
||||||
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
|
|
||||||
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendOption(
|
function appendOption(
|
||||||
@ -110,10 +158,19 @@ function appendOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
return {
|
const toolbarDom = {
|
||||||
exportButton: root.querySelector(
|
exportButton: root.querySelector(
|
||||||
'[data-plugin-export="button"]'
|
'[data-plugin-export="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
|
exportCustomPagesInput: root.querySelector(
|
||||||
|
'[data-plugin-export-custom-pages="input"]'
|
||||||
|
) as HTMLInputElement,
|
||||||
|
exportRangeSelect: root.querySelector(
|
||||||
|
'[data-plugin-export-range="select"]'
|
||||||
|
) as HTMLSelectElement,
|
||||||
|
exportStatusText: root.querySelector(
|
||||||
|
'[data-plugin-export-status="text"]'
|
||||||
|
) as HTMLElement,
|
||||||
filterApplyButton: root.querySelector(
|
filterApplyButton: root.querySelector(
|
||||||
'[data-plugin-filter-apply="button"]'
|
'[data-plugin-filter-apply="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
@ -133,5 +190,93 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
|||||||
sortFieldSelect: root.querySelector(
|
sortFieldSelect: root.querySelector(
|
||||||
'[data-plugin-sort-field="select"]'
|
'[data-plugin-sort-field="select"]'
|
||||||
) as HTMLSelectElement
|
) as HTMLSelectElement
|
||||||
|
} satisfies PluginToolbarDom;
|
||||||
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
|
return toolbarDom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToolbarExportTarget(
|
||||||
|
toolbar: PluginToolbarDom
|
||||||
|
): { error?: string; target?: MarketExportTarget } {
|
||||||
|
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
|
||||||
|
|
||||||
|
if (scope === "all") {
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
mode: "all"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "current") {
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
mode: "count",
|
||||||
|
pageCount: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "first-5") {
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
mode: "count",
|
||||||
|
pageCount: 5
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "first-10") {
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
mode: "count",
|
||||||
|
pageCount: 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = Number(toolbar.exportCustomPagesInput.value);
|
||||||
|
if (!Number.isInteger(pageCount) || pageCount < 1) {
|
||||||
|
return {
|
||||||
|
error: "请输入有效页数"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
mode: "count",
|
||||||
|
pageCount
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setToolbarBusyState(
|
||||||
|
toolbar: PluginToolbarDom,
|
||||||
|
isBusy: boolean
|
||||||
|
): void {
|
||||||
|
[
|
||||||
|
toolbar.exportButton,
|
||||||
|
toolbar.filterApplyButton,
|
||||||
|
toolbar.sortApplyButton,
|
||||||
|
toolbar.singleFilterInput,
|
||||||
|
toolbar.personalFilterInput,
|
||||||
|
toolbar.sortFieldSelect,
|
||||||
|
toolbar.sortDirectionSelect,
|
||||||
|
toolbar.exportRangeSelect,
|
||||||
|
toolbar.exportCustomPagesInput
|
||||||
|
].forEach((element) => {
|
||||||
|
element.disabled = isBusy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToolbarExportStatus(
|
||||||
|
toolbar: PluginToolbarDom,
|
||||||
|
text: string
|
||||||
|
): void {
|
||||||
|
toolbar.exportStatusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||||
|
toolbar.exportCustomPagesInput.hidden =
|
||||||
|
toolbar.exportRangeSelect.value !== "custom";
|
||||||
|
}
|
||||||
|
|||||||
@ -37,14 +37,24 @@ export function createMarketResultStore() {
|
|||||||
upsertMarketRow(row: MarketRowSnapshot) {
|
upsertMarketRow(row: MarketRowSnapshot) {
|
||||||
const existingRecord = records.get(row.authorId);
|
const existingRecord = records.get(row.authorId);
|
||||||
if (existingRecord) {
|
if (existingRecord) {
|
||||||
|
existingRecord.authorName =
|
||||||
|
mergeStringValue(existingRecord.authorName, row.authorName) ??
|
||||||
|
existingRecord.authorName;
|
||||||
|
existingRecord.location = mergeStringValue(
|
||||||
|
existingRecord.location,
|
||||||
|
row.location
|
||||||
|
);
|
||||||
|
existingRecord.price21To60s = mergeStringValue(
|
||||||
|
existingRecord.price21To60s,
|
||||||
|
row.price21To60s
|
||||||
|
);
|
||||||
|
existingRecord.exportFields = mergeFieldMap(
|
||||||
|
existingRecord.exportFields,
|
||||||
|
row.exportFields
|
||||||
|
);
|
||||||
existingRecord.hasDirectRatesSource =
|
existingRecord.hasDirectRatesSource =
|
||||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||||
if (row.rates) {
|
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||||
existingRecord.rates = {
|
|
||||||
...existingRecord.rates,
|
|
||||||
...row.rates
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return existingRecord;
|
return existingRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,3 +82,40 @@ export function createMarketResultStore() {
|
|||||||
return nextRecord;
|
return nextRecord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||||
|
current: T | undefined,
|
||||||
|
incoming: T | undefined
|
||||||
|
): T | undefined {
|
||||||
|
if (!current && !incoming) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...(current ?? {})
|
||||||
|
} as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
Object.entries(incoming ?? {}).forEach(([key, value]) => {
|
||||||
|
const currentValue = merged[key];
|
||||||
|
if (!hasTextValue(currentValue)) {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStringValue(
|
||||||
|
current: string | undefined,
|
||||||
|
incoming: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasTextValue(current)) {
|
||||||
|
return incoming ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTextValue(value: string | undefined): boolean {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi
|
|||||||
export interface MarketRowSnapshot {
|
export interface MarketRowSnapshot {
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
exportFields?: Record<string, string>;
|
||||||
hasDirectRatesSource?: boolean;
|
hasDirectRatesSource?: boolean;
|
||||||
location?: string;
|
location?: string;
|
||||||
price21To60s?: string;
|
price21To60s?: string;
|
||||||
@ -24,6 +25,17 @@ export interface MarketFilterState {
|
|||||||
singleVideoAfterSearchRateMin?: number;
|
singleVideoAfterSearchRateMin?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarketExportScope = "current" | "first-5" | "first-10" | "all" | "custom";
|
||||||
|
|
||||||
|
export type MarketExportTarget =
|
||||||
|
| {
|
||||||
|
mode: "all";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "count";
|
||||||
|
pageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface MarketSortState {
|
export interface MarketSortState {
|
||||||
direction: "asc" | "desc";
|
direction: "asc" | "desc";
|
||||||
field: keyof Required<AfterSearchRates>;
|
field: keyof Required<AfterSearchRates>;
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Star Chart Search Enhancer",
|
"name": "Star Chart Search Enhancer",
|
||||||
"version": "0.0.0",
|
"version": "0.2.0421.2",
|
||||||
"description": "Bootstraps the Xingtu creator market content script.",
|
"description": "Bootstraps the Xingtu creator market content script.",
|
||||||
|
"permissions": ["downloads"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background/index.js"
|
||||||
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
|
|||||||
50
tests/background-index.test.ts
Normal file
50
tests/background-index.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { registerBackgroundMessageHandler } from "../src/background/index";
|
||||||
|
|
||||||
|
describe("background-index", () => {
|
||||||
|
test("downloads csv files when the content script sends a download message", async () => {
|
||||||
|
const listeners: Array<
|
||||||
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
> = [];
|
||||||
|
const download = vi.fn(async () => undefined);
|
||||||
|
const sendResponse = vi.fn();
|
||||||
|
|
||||||
|
registerBackgroundMessageHandler({
|
||||||
|
downloads: {
|
||||||
|
download
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
onMessage: {
|
||||||
|
addListener(listener) {
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listeners).toHaveLength(1);
|
||||||
|
const result = listeners[0](
|
||||||
|
{
|
||||||
|
csv: "列1,列2\n值1,值2",
|
||||||
|
filename: "market.csv",
|
||||||
|
type: "download-market-csv"
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
sendResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(download).toHaveBeenCalledTimes(1);
|
||||||
|
expect(download).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: "market.csv",
|
||||||
|
saveAs: false,
|
||||||
|
url: expect.stringContaining("data:text/csv;charset=utf-8,")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@ import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
|||||||
import type { MarketRecord } from "../src/content/market/types";
|
import type { MarketRecord } from "../src/content/market/types";
|
||||||
|
|
||||||
describe("csv-exporter", () => {
|
describe("csv-exporter", () => {
|
||||||
test("uses the expected header order", () => {
|
test("uses fallback header order when no page export fields are available", () => {
|
||||||
const csv = buildMarketCsv([]);
|
const csv = buildMarketCsv([]);
|
||||||
const [headerLine] = csv.split("\n");
|
const [headerLine] = csv.split("\n");
|
||||||
|
|
||||||
@ -15,12 +15,38 @@ describe("csv-exporter", () => {
|
|||||||
"地区",
|
"地区",
|
||||||
"21-60s报价",
|
"21-60s报价",
|
||||||
"单视频看后搜率",
|
"单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率"
|
||||||
"插件数据状态"
|
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses page export field order and appends the two plugin columns", () => {
|
||||||
|
const csv = buildMarketCsv([
|
||||||
|
{
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "Alice",
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "Alice",
|
||||||
|
粉丝数: "100w",
|
||||||
|
"21-60s报价": "¥450,000"
|
||||||
|
},
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "1% - 3%"
|
||||||
|
}
|
||||||
|
} satisfies MarketRecord
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
expect(headerLine).toBe(
|
||||||
|
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
|
||||||
|
","
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
|
||||||
|
});
|
||||||
|
|
||||||
test("escapes commas and quotes", () => {
|
test("escapes commas and quotes", () => {
|
||||||
const csv = buildMarketCsv([
|
const csv = buildMarketCsv([
|
||||||
{
|
{
|
||||||
@ -51,7 +77,7 @@ describe("csv-exporter", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
const [, rowLine] = csv.split("\n");
|
||||||
expect(rowLine).toBe("123,Alice,,,,,failed");
|
expect(rowLine).toBe("123,Alice,,,,");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses normalized display values in export rows", () => {
|
test("uses normalized display values in export rows", () => {
|
||||||
|
|||||||
@ -10,4 +10,11 @@ describe("manifest", () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("declares the downloads permission and background worker for csv export", () => {
|
||||||
|
expect(manifest.permissions).toEqual(
|
||||||
|
expect.arrayContaining(["downloads"])
|
||||||
|
);
|
||||||
|
expect(manifest.background?.service_worker).toBe("background/index.js");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,9 @@ import { beforeEach, describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyRowOrder,
|
applyRowOrder,
|
||||||
applyRowVisibility,
|
applyRowVisibility,
|
||||||
|
findNextPageControl,
|
||||||
|
isPageControlDisabled,
|
||||||
|
readMarketPageSignature,
|
||||||
renderMarketRowState,
|
renderMarketRowState,
|
||||||
syncMarketTable
|
syncMarketTable
|
||||||
} from "../src/content/market/dom-sync";
|
} from "../src/content/market/dom-sync";
|
||||||
@ -154,6 +157,11 @@ describe("market-dom-sync", () => {
|
|||||||
|
|
||||||
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
||||||
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
|
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
|
||||||
|
expect(table.rows[0].exportFields).toMatchObject({
|
||||||
|
"21-60s报价": "¥450,000",
|
||||||
|
"代表视频": "代表视频A",
|
||||||
|
"达人信息": "达人 A"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to the market vue state when the DOM has no author id", () => {
|
test("falls back to the market vue state when the DOM has no author id", () => {
|
||||||
@ -208,6 +216,33 @@ describe("market-dom-sync", () => {
|
|||||||
singleVideoAfterSearchRate: "0.02%"
|
singleVideoAfterSearchRate: "0.02%"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("finds the real next-page button in Xingtu pagination", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
${buildRealMarketGridFixture()}
|
||||||
|
<div class="pagination xt-space xt-space--medium">
|
||||||
|
<div class="el-pagination is-background xt-pagination xt-pagination--normal">
|
||||||
|
<button type="button" disabled="disabled" class="btn-prev">
|
||||||
|
<i class="el-icon el-icon-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="el-pager">
|
||||||
|
<li class="number active">1</li>
|
||||||
|
<li class="number">2</li>
|
||||||
|
</ul>
|
||||||
|
<button type="button" class="btn-next">
|
||||||
|
<i class="el-icon el-icon-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const nextControl = findNextPageControl(document);
|
||||||
|
|
||||||
|
expect(nextControl).not.toBeNull();
|
||||||
|
expect(nextControl?.className).toContain("btn-next");
|
||||||
|
expect(isPageControlDisabled(nextControl)).toBe(false);
|
||||||
|
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildRealMarketGridFixture() {
|
function buildRealMarketGridFixture() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user