feat: add yuntu report filling automation

This commit is contained in:
wxs 2026-03-31 18:52:34 +08:00
parent c0a531fd1d
commit c8b5d27078
17 changed files with 2996 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
**/node_modules/ **/node_modules/
**/.DS_Store **/.DS_Store
**/.env

View File

@ -0,0 +1,69 @@
# Yuntu Report Server Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local Node.js backend that exposes `POST /api/reports` for the browser script and persists validated report records into PostgreSQL.
**Architecture:** Use a small Express server with a thin HTTP layer, isolated validation/mapping utilities, and a PostgreSQL repository backed by parameterized SQL. Keep report validation and database row mapping in pure functions so they can be covered with fast `node:test` tests before wiring the HTTP server.
**Tech Stack:** Node.js, Express, PostgreSQL (`pg`), `dotenv`, built-in `node:test`
---
### Task 1: Bootstrap the local server package
**Files:**
- Create: `yuntu/yuntuReportFilling/server/package.json`
- Create: `yuntu/yuntuReportFilling/server/.env.example`
- Create: `yuntu/yuntuReportFilling/server/README.md`
- [ ] **Step 1: Create the package manifest**
- [ ] **Step 2: Add local development scripts for start, dev, and test**
- [ ] **Step 3: Add `.env.example` documenting required database and server variables**
- [ ] **Step 4: Add a minimal README with startup instructions**
### Task 2: Write failing tests for validation and mapping
**Files:**
- Create: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
- [ ] **Step 1: Write a test for accepting a valid manual report payload**
- [ ] **Step 2: Run the test and verify it fails because the module does not exist yet**
- [ ] **Step 3: Write a test for rejecting `AUTO_COPY` without `sourceReportId`**
- [ ] **Step 4: Write a test for mapping API payload fields into database-ready column values**
### Task 3: Implement the pure report service module
**Files:**
- Create: `yuntu/yuntuReportFilling/server/src/report-service.js`
- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
- [ ] **Step 1: Implement payload validation helpers**
- [ ] **Step 2: Implement normalization/mapping helpers for database insertion**
- [ ] **Step 3: Re-run the focused test suite and verify it passes**
### Task 4: Implement the database repository and HTTP server
**Files:**
- Create: `yuntu/yuntuReportFilling/server/src/db.js`
- Create: `yuntu/yuntuReportFilling/server/src/report-repository.js`
- Create: `yuntu/yuntuReportFilling/server/src/server.js`
- Create: `yuntu/yuntuReportFilling/server/src/index.js`
- Modify: `yuntu/yuntuReportFilling/PRD.md`
- [ ] **Step 1: Add PostgreSQL pool creation from environment variables**
- [ ] **Step 2: Add repository insert logic with idempotent `report_id` handling**
- [ ] **Step 3: Add Express routes for `GET /health` and `POST /api/reports`**
- [ ] **Step 4: Add CORS and JSON parsing suitable for local browser-script access**
- [ ] **Step 5: Re-run tests and add any missing focused coverage**
### Task 5: Verify the local backend end-to-end
**Files:**
- Verify: `yuntu/yuntuReportFilling/server/*`
- [ ] **Step 1: Install dependencies**
- [ ] **Step 2: Run `npm test` and confirm all tests pass**
- [ ] **Step 3: Start the server locally and confirm `GET /health` returns success**
- [ ] **Step 4: Summarize required next step for integrating the browser script**

View File

@ -0,0 +1,405 @@
# 云图报告自动创建 PRD
## 一、项目基本信息
- 项目路径:`/Users/wxs/projects/browser_script/scriptCat/yuntu/yuntuReportFilling`
- 项目类型ScriptCat 浏览器脚本 + Node.js 后端服务 + PostgreSQL
- 业务目标:在云图页面中复用最近一次成功创建报告的请求参数,自动创建一份同比报告,并将关键数据持久化到数据库
- 核心价值:
- 降低人工重复创建报告的成本
- 自动将时间范围回退一年,方便做同比分析
- 为后续追溯和排查保留完整 payload 与 report_id
## 二、业务背景
当前用户在云图页面手动创建报告后,还需要再次输入或复制相同配置,才能创建同比报告。该过程重复且容易出错。
本项目通过监听用户首次手动创建报告时的请求与响应,缓存最近一次成功的 payload当用户点击右侧按钮后脚本自动将顶层 `startTime``endTime` 各回退一年,并再次调用云图创建接口完成报告复制。
## 三、范围说明
### 本期范围
- 监听并识别云图“创建报告”接口
- 记录最近一次成功创建报告的完整 payload
- 在页面右侧提供“一键复制同比报告”按钮
- 自动回退顶层 `startTime` / `endTime` 一年
- 调用后端 API 持久化成功创建的报告数据
- 将手动创建报告和脚本自动创建报告统一写入 PostgreSQL
### 本期不做
- 不从数据库反向同步最新 payload 到浏览器本地
- 不支持多条历史 payload 管理
- 不支持用户手动编辑复制前的字段
- 不修改除顶层 `startTime` / `endTime` 外的其他字段
## 四、目标页面与第三方接口
### 4.1 目标页面
- 页面域名:`https://yuntu.oceanengine.com`
- 目标页面路径:`/yuntu_brand/ecom/product/segmentedMarketcreation`
- 页面示例:`https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140`
### 4.2 需监听与复用的云图接口
- 请求方法:`POST`
- 接口路径:`/product_node/v2/api/segmentedMarket/createSegmentedMarket`
- 完整示例:
- `https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140`
- 关键识别条件:
- 请求方法必须为 `POST`
- 路径必须匹配 `createSegmentedMarket`
- 查询参数中包含 `aadvid`
### 4.3 第三方接口约束
- 浏览器脚本调用该接口时,必须复用当前用户浏览器会话中的登录态
- 不允许在脚本或后端中硬编码 Cookie、CSRF Token 等敏感信息
- 只允许浏览器脚本直接调用云图接口;后端不直接代调云图接口
- 接口成功后,从响应体 `data.reportId` 中提取业务 `report_id`
## 五、核心流程
### 5.1 手动创建报告并记录
触发时机:用户在页面中手动点击“创建报告”并且云图接口返回成功。
执行流程:
1. 监听 `createSegmentedMarket` 请求与响应。
2. 提取请求 payload 与请求 URL 中的 `aadvid`
3. 校验响应成功,且响应体存在 `data.reportId`
4. 将以下内容写入浏览器本地:
- `lastReportPayload`:最近一次成功创建报告的完整 payload
- `lastReportMeta`:最近一次成功创建报告的元信息,至少包含 `reportId``aadvid``sourceType``capturedAt`
5. 如果本地写入失败,立即报错,并保持按钮不可用状态;本次不调用后端持久化接口。
6. 如果本地写入成功,调用后端 API 将本次手动创建报告写入数据库。
### 5.2 一键复制同比报告
触发时机:用户点击页面右侧“一键复制同比报告”按钮。
执行流程:
1. 读取 `lastReportPayload``lastReportMeta`
2. 若本地不存在有效 payload则按钮保持置灰不允许点击。
3. 深拷贝 payload仅修改顶层 `startTime``endTime`
4. 使用修改后的 payload 调用云图创建接口。
5. 接口成功后,从响应体中读取新的 `data.reportId`
6. 先将新的 payload 和元信息覆盖写入本地存储。
7. 本地写入成功后,调用后端 API 持久化本次自动复制创建的报告数据。
8. 持久化成功后,给出成功提示,并显示新的 `report_id`
失败处理:
- 云图接口调用失败:提示“同比报告创建失败”,保留旧的本地 payload不调用后端 API。
- 本地覆盖写入失败:提示“本地缓存更新失败”,不调用后端 API按钮恢复可点击。
- 后端持久化失败:提示“报告已创建成功,但数据库记录失败”,保留新的本地 payload按钮恢复可点击。
## 六、时间处理规则
### 6.1 处理目标
将顶层 `startTime``endTime` 各回退一个自然年,用于生成同比报告。
### 6.2 处理原则
- 只处理 payload 顶层字段:
- `startTime`
- `endTime`
- 其他字段保持原值不变
- 日期格式统一为 `YYYY-MM-DD`
- 使用“自然年回退”规则,而不是简单减去 365 天
### 6.3 具体算法
1. 将 `startTime``endTime` 解析为日期对象。
2. 分别将年份减 1尽量保持原始月与日不变。
3. 如果目标年份不存在该日期,则回退到目标月份最后一天。
示例:
```javascript
// 正常日期
2025-03-01 -> 2024-03-01
2026-02-28 -> 2025-02-28
// 闰年特殊情况
2024-02-29 -> 2023-02-28
```
该规则可以最大限度保持同比区间的自然日语义。
## 七、页面交互设计
### 7.1 按钮位置
- 按钮固定显示在页面右侧中部
- 文案建议:`一键复制同比报告`
### 7.2 按钮状态
- 默认状态:
- 当本地存在有效 `lastReportPayload` 时可点击
- 当本地不存在有效 `lastReportPayload` 时置灰且不可点击
- 加载状态:
- 点击后立即进入 loading
- loading 期间禁止再次点击,防止重复创建
- 完成状态:
- 成功后 toast 提示创建成功,并展示新 `report_id`
- 成功后不自动跳转页面,保持用户留在当前页面,避免打断连续操作
### 7.3 状态恢复规则
- 用户再次手动成功创建报告后,按钮状态根据本地缓存重新计算
- 任何失败场景都需要正确退出 loading 状态
## 八、浏览器本地存储设计
### 8.1 存储方式
- 使用 `GM_setValue` / `GM_getValue`
### 8.2 存储键
- `lastReportPayload`
- `lastReportMeta`
### 8.3 存储内容
`lastReportPayload`
```json
{
"endTime": "2026-02-28",
"name": "测试",
"periodType": "MONTH",
"startTime": "2025-03-01"
}
```
`lastReportMeta`
```json
{
"reportId": "123456789",
"aadvid": "1648829117232140",
"sourceType": "MANUAL_CAPTURE",
"capturedAt": "2026-03-31T15:00:00+08:00"
}
```
### 8.4 更新规则
- 每次成功创建报告后覆盖写入
- 始终只保留最近一次成功创建报告的数据
- 脚本启动时不从数据库同步数据
## 九、后端 API 设计
后端职责仅为持久化成功创建的报告数据,不直接调用云图接口。
### 9.1 保存报告记录
- 方法:`POST`
- 路径:`/api/reports`
- 用途:保存一条已创建成功的报告记录
请求体:
```json
{
"reportId": "123456789",
"sourceType": "AUTO_COPY",
"sourceReportId": "987654321",
"aadvid": "1648829117232140",
"name": "测试",
"price": ["1,100", "101,100000"],
"rules": [
{
"keywords": ["奶粉"],
"op": "INCLUDE"
}
],
"analysisDims": [
"MARKETOVERVIEW",
"PRODUCTINSIGHT",
"MARKETCOMPOSING",
"CONSUMERINSIGHT"
],
"categories": [],
"channels": ["ALL"],
"startTime": "2024-03-01",
"endTime": "2025-02-28",
"periodType": "MONTH",
"userName": "qichumiaosi@163.com",
"payload": {}
}
```
字段说明:
- `reportId`:云图响应中的 `data.reportId`
- `sourceType`:取值为 `MANUAL_CAPTURE``AUTO_COPY`
- `sourceReportId`
- `MANUAL_CAPTURE` 时可为空
- `AUTO_COPY` 时必填,表示复制来源报告 ID
- `aadvid`:从页面 URL 或请求 URL 中提取
- `payload`:完整请求体,原样保存
成功响应:
```json
{
"success": true,
"data": {
"id": 1,
"reportId": "123456789"
}
}
```
失败响应:
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "sourceReportId is required when sourceType is AUTO_COPY"
}
}
```
### 9.2 后端校验规则
- `reportId` 必填
- `sourceType` 必须为 `MANUAL_CAPTURE``AUTO_COPY`
- `AUTO_COPY` 场景下 `sourceReportId` 必填
- `payload` 必须为合法 JSON
- `startTime``endTime` 必须符合 `YYYY-MM-DD`
- 数据库以 `report_id` 做唯一约束,重复写入时返回成功并提示已存在,保证接口幂等
## 十、数据库设计
### 10.1 表设计说明
表名:`public.yuntu_report_info`
字段设计目标:
- 记录云图返回的业务 `report_id`
- 区分手动创建和自动复制
- 能追溯自动复制来源
- 保存结构化字段与完整 payload
### 10.2 建表 SQL
```sql
DROP TABLE IF EXISTS public.yuntu_report_info;
CREATE TABLE public.yuntu_report_info (
id BIGSERIAL PRIMARY KEY,
report_id VARCHAR(64) NOT NULL,
source_type VARCHAR(32) NOT NULL,
source_report_id VARCHAR(64) NULL,
aadvid VARCHAR(32) NOT NULL,
name TEXT NOT NULL,
price JSONB NOT NULL DEFAULT '[]'::jsonb,
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
start_time DATE NOT NULL,
end_time DATE NOT NULL,
period_type VARCHAR(32) NULL,
user_name TEXT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
CONSTRAINT chk_yuntu_report_info_source_type
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
CONSTRAINT chk_yuntu_report_info_copy_source
CHECK (
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
OR
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
)
);
CREATE INDEX idx_yuntu_report_info_source_report_id
ON public.yuntu_report_info (source_report_id);
CREATE INDEX idx_yuntu_report_info_created_at
ON public.yuntu_report_info (created_at DESC);
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
ON public.yuntu_report_info (aadvid, created_at DESC);
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
CREATE TRIGGER trg_yuntu_report_info_updated_at
BEFORE UPDATE ON public.yuntu_report_info
FOR EACH ROW
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();
```
### 10.3 字段映射
| 数据库字段 | 来源字段 | 说明 |
| --- | --- | --- |
| `report_id` | `response.data.reportId` | 云图报告 ID |
| `source_type` | 脚本内部标记 | 手动捕获或自动复制 |
| `source_report_id` | `lastReportMeta.reportId` | 自动复制来源报告 ID |
| `aadvid` | query 参数 `aadvid` | 广告主 ID |
| `name` | `payload.name` | 报告名称 |
| `price` | `payload.price` | 价格区间 |
| `rules` | `payload.rules` | 规则配置 |
| `analysis_dims` | `payload.analysisDims` | 分析维度 |
| `categories` | `payload.categories` | 类目树 |
| `transaction_channels` | `payload.channels` | 渠道信息 |
| `start_time` | `payload.startTime` | 报告开始日期 |
| `end_time` | `payload.endTime` | 报告结束日期 |
| `period_type` | `payload.periodType` | 周期类型 |
| `user_name` | `payload.userName` | 用户名 |
| `payload` | 完整 payload | 原始请求体 |
## 十一、错误处理
### 11.1 浏览器脚本侧
- 拦截不到目标接口:不报错,不显示成功态
- 响应中缺少 `data.reportId`:提示“未获取到 report_id无法记录”
- 本地存储失败:立即报错,并终止当前脚本链路
- 用户重复点击:忽略后续点击并保持 loading
- 请求超时:提示失败并恢复按钮
### 11.2 后端侧
- 参数校验失败:返回 4xx 与明确错误信息
- 数据库写入失败:返回 5xx 与错误码
- 重复 `report_id` 写入:按幂等成功处理,不重复插入
## 十二、验收标准
1. 用户在目标页面手动成功创建报告后,脚本能够识别目标接口,并从响应体中正确提取 `data.reportId`
2. 手动创建成功后,本地必须成功写入 `lastReportPayload``lastReportMeta`;若写入失败,页面明确报错且按钮不可点击。
3. 手动创建成功且本地写入成功后,后端数据库新增一条 `source_type = 'MANUAL_CAPTURE'` 的记录,且 `report_id` 与云图响应一致。
4. 当本地不存在有效 payload 时,右侧按钮必须置灰且无法点击。
5. 点击“一键复制同比报告”后,脚本只能修改顶层 `startTime``endTime`,其他字段与来源 payload 保持一致。
6. 自动复制成功后,新报告的时间范围必须按自然年回退一年的规则生成;闰年日期按目标月最后一天处理。
7. 自动复制成功后,本地缓存必须被新 payload 和新 `reportId` 覆盖;数据库新增一条 `source_type = 'AUTO_COPY'` 的记录,且 `source_report_id` 正确指向来源报告。
8. 点击按钮进入 loading 后,用户无法重复触发创建;无论成功还是失败,流程结束后按钮状态必须恢复正确。
9. 后端接口重复接收同一个 `report_id` 时,不得插入重复数据。
10. 整个方案不得在代码或配置中硬编码用户 Cookie、Token 等敏感登录信息。

View File

@ -0,0 +1,3 @@
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/yuntu_report
ALLOWED_ORIGIN=https://yuntu.oceanengine.com

View File

@ -0,0 +1,18 @@
# Yuntu Report Local Server
## Setup
1. Copy `.env.example` to `.env`.
2. Update `DATABASE_URL` to point at your local PostgreSQL instance.
3. Optionally adjust `ALLOWED_ORIGIN` if you need to call the server from another origin.
4. Install dependencies with `npm install`.
5. Start the server with `npm run dev` or `npm start`.
## Endpoints
- `GET /health`
- `POST /api/reports`
## Default URL
- `http://localhost:3000`

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
{
"name": "yuntu-report-filling-server",
"version": "1.0.0",
"private": true,
"description": "Local backend for persisting Yuntu report creation records",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"pg": "^8.13.1"
}
}

View File

@ -0,0 +1,55 @@
DROP TABLE IF EXISTS public.yuntu_report_info;
CREATE TABLE public.yuntu_report_info (
id BIGSERIAL PRIMARY KEY,
report_id VARCHAR(64) NOT NULL,
source_type VARCHAR(32) NOT NULL,
source_report_id VARCHAR(64) NULL,
aadvid VARCHAR(32) NOT NULL,
name TEXT NOT NULL,
price JSONB NOT NULL DEFAULT '[]'::jsonb,
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
start_time DATE NOT NULL,
end_time DATE NOT NULL,
period_type VARCHAR(32) NULL,
user_name TEXT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
CONSTRAINT chk_yuntu_report_info_source_type
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
CONSTRAINT chk_yuntu_report_info_copy_source
CHECK (
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
OR
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
)
);
CREATE INDEX idx_yuntu_report_info_source_report_id
ON public.yuntu_report_info (source_report_id);
CREATE INDEX idx_yuntu_report_info_created_at
ON public.yuntu_report_info (created_at DESC);
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
ON public.yuntu_report_info (aadvid, created_at DESC);
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
CREATE TRIGGER trg_yuntu_report_info_updated_at
BEFORE UPDATE ON public.yuntu_report_info
FOR EACH ROW
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();

View File

@ -0,0 +1,15 @@
const { Pool } = require('pg');
function createPool(env = process.env) {
if (!env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
}
return new Pool({
connectionString: env.DATABASE_URL,
});
}
module.exports = {
createPool,
};

View File

@ -0,0 +1,36 @@
const dotenv = require('dotenv');
const { createPool } = require('./db');
const { createRepository } = require('./report-repository');
const { createApp } = require('./server');
dotenv.config();
const port = Number.parseInt(process.env.PORT || '3000', 10);
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://yuntu.oceanengine.com';
async function start() {
const pool = createPool(process.env);
const repository = createRepository(pool);
const app = createApp({ repository, allowedOrigin });
const server = app.listen(port, () => {
console.log(`Yuntu report server listening on http://localhost:${port}`);
});
const shutdown = async () => {
server.close(async () => {
await pool.end();
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
start().catch((error) => {
console.error('Failed to start Yuntu report server');
console.error(error);
process.exit(1);
});

View File

@ -0,0 +1,86 @@
const INSERT_REPORT_SQL = `
INSERT INTO public.yuntu_report_info (
report_id,
source_type,
source_report_id,
aadvid,
name,
price,
rules,
analysis_dims,
categories,
transaction_channels,
start_time,
end_time,
period_type,
user_name,
payload
)
VALUES (
$1, $2, $3, $4, $5,
$6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11, $12, $13, $14, $15::jsonb
)
ON CONFLICT (report_id) DO NOTHING
RETURNING id, report_id
`;
const FIND_REPORT_SQL = `
SELECT id, report_id
FROM public.yuntu_report_info
WHERE report_id = $1
`;
function createRepository(pool) {
return {
async save(record) {
const insertValues = [
record.report_id,
record.source_type,
record.source_report_id,
record.aadvid,
record.name,
JSON.stringify(record.price),
JSON.stringify(record.rules),
JSON.stringify(record.analysis_dims),
JSON.stringify(record.categories),
JSON.stringify(record.transaction_channels),
record.start_time,
record.end_time,
record.period_type,
record.user_name,
JSON.stringify(record.payload),
];
const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues);
if (insertResult.rows.length > 0) {
const row = insertResult.rows[0];
return {
id: row.id,
reportId: row.report_id,
created: true,
};
}
const existingResult = await pool.query(FIND_REPORT_SQL, [record.report_id]);
const existingRow = existingResult.rows[0];
if (!existingRow) {
throw new Error(`report_id ${record.report_id} was not found after conflict`);
}
return {
id: existingRow.id,
reportId: existingRow.report_id,
created: false,
};
},
};
}
module.exports = {
createRepository,
FIND_REPORT_SQL,
INSERT_REPORT_SQL,
};

View File

@ -0,0 +1,115 @@
const ALLOWED_SOURCE_TYPES = new Set(['MANUAL_CAPTURE', 'AUTO_COPY']);
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
this.code = 'VALIDATION_ERROR';
}
}
function ensureNonEmptyString(value, fieldName) {
if (typeof value !== 'string' || value.trim() === '') {
throw new ValidationError(`${fieldName} is required`);
}
return value.trim();
}
function ensureOptionalString(value, fieldName) {
if (value == null || value === '') {
return null;
}
return ensureNonEmptyString(value, fieldName);
}
function ensureArray(value, fieldName) {
if (!Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an array`);
}
return value;
}
function ensureObject(value, fieldName) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an object`);
}
return value;
}
function ensureDateString(value, fieldName) {
const normalized = ensureNonEmptyString(value, fieldName);
if (!DATE_PATTERN.test(normalized)) {
throw new ValidationError(`${fieldName} must match YYYY-MM-DD`);
}
return normalized;
}
function validateAndNormalizeReportInput(input) {
const payload = ensureObject(input, 'body');
const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType');
if (!ALLOWED_SOURCE_TYPES.has(sourceType)) {
throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY');
}
const sourceReportId = ensureOptionalString(payload.sourceReportId, 'sourceReportId');
if (sourceType === 'MANUAL_CAPTURE' && sourceReportId !== null) {
throw new ValidationError('sourceReportId must be empty for MANUAL_CAPTURE');
}
if (sourceType === 'AUTO_COPY' && sourceReportId === null) {
throw new ValidationError('sourceReportId is required when sourceType is AUTO_COPY');
}
return {
reportId: ensureNonEmptyString(payload.reportId, 'reportId'),
sourceType,
sourceReportId,
aadvid: ensureNonEmptyString(payload.aadvid, 'aadvid'),
name: ensureNonEmptyString(payload.name, 'name'),
price: ensureArray(payload.price, 'price'),
rules: ensureArray(payload.rules, 'rules'),
analysisDims: ensureArray(payload.analysisDims, 'analysisDims'),
categories: ensureArray(payload.categories, 'categories'),
channels: ensureArray(payload.channels, 'channels'),
startTime: ensureDateString(payload.startTime, 'startTime'),
endTime: ensureDateString(payload.endTime, 'endTime'),
periodType: ensureOptionalString(payload.periodType, 'periodType'),
userName: ensureOptionalString(payload.userName, 'userName'),
payload: ensureObject(payload.payload, 'payload'),
};
}
function toDatabaseRecord(normalizedReport) {
return {
report_id: normalizedReport.reportId,
source_type: normalizedReport.sourceType,
source_report_id: normalizedReport.sourceReportId,
aadvid: normalizedReport.aadvid,
name: normalizedReport.name,
price: normalizedReport.price,
rules: normalizedReport.rules,
analysis_dims: normalizedReport.analysisDims,
categories: normalizedReport.categories,
transaction_channels: normalizedReport.channels,
start_time: normalizedReport.startTime,
end_time: normalizedReport.endTime,
period_type: normalizedReport.periodType,
user_name: normalizedReport.userName,
payload: normalizedReport.payload,
};
}
module.exports = {
ValidationError,
validateAndNormalizeReportInput,
toDatabaseRecord,
};

View File

@ -0,0 +1,70 @@
const cors = require('cors');
const express = require('express');
const {
ValidationError,
toDatabaseRecord,
validateAndNormalizeReportInput,
} = require('./report-service');
function createApp({ repository, allowedOrigin }) {
const app = express();
app.use(
cors({
origin: allowedOrigin,
}),
);
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => {
response.json({
success: true,
data: {
status: 'ok',
},
});
});
app.post('/api/reports', async (request, response) => {
try {
const normalizedReport = validateAndNormalizeReportInput(request.body);
const record = toDatabaseRecord(normalizedReport);
const saved = await repository.save(record);
response.status(saved.created ? 201 : 200).json({
success: true,
data: {
id: saved.id,
reportId: saved.reportId,
created: saved.created,
},
});
} catch (error) {
if (error instanceof ValidationError) {
response.status(400).json({
success: false,
error: {
code: error.code,
message: error.message,
},
});
return;
}
response.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Unexpected server error',
},
});
}
});
return app;
}
module.exports = {
createApp,
};

View File

@ -0,0 +1,95 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
validateAndNormalizeReportInput,
toDatabaseRecord,
} = require('../src/report-service');
function createManualPayload() {
return {
reportId: 'report-001',
sourceType: 'MANUAL_CAPTURE',
sourceReportId: null,
aadvid: '1648829117232140',
name: '测试报告',
price: ['1,100', '101,100000'],
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
analysisDims: ['MARKETOVERVIEW'],
categories: [{ id: '20028', name: '奶粉类目' }],
channels: ['ALL'],
startTime: '2025-03-01',
endTime: '2026-02-28',
periodType: 'MONTH',
userName: 'tester@example.com',
payload: {
name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
},
};
}
test('validateAndNormalizeReportInput accepts a valid manual report payload', () => {
const input = createManualPayload();
const result = validateAndNormalizeReportInput(input);
assert.equal(result.reportId, 'report-001');
assert.equal(result.sourceType, 'MANUAL_CAPTURE');
assert.equal(result.sourceReportId, null);
assert.equal(result.aadvid, '1648829117232140');
assert.equal(result.startTime, '2025-03-01');
assert.equal(result.endTime, '2026-02-28');
});
test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => {
const input = {
...createManualPayload(),
reportId: 'report-002',
sourceType: 'AUTO_COPY',
sourceReportId: '',
};
assert.throws(
() => validateAndNormalizeReportInput(input),
(error) => {
assert.equal(error.code, 'VALIDATION_ERROR');
assert.match(error.message, /sourceReportId/i);
return true;
},
);
});
test('toDatabaseRecord maps API payload fields into database-ready values', () => {
const normalized = validateAndNormalizeReportInput({
...createManualPayload(),
reportId: 'report-003',
sourceType: 'AUTO_COPY',
sourceReportId: 'report-001',
});
const record = toDatabaseRecord(normalized);
assert.deepEqual(record, {
report_id: 'report-003',
source_type: 'AUTO_COPY',
source_report_id: 'report-001',
aadvid: '1648829117232140',
name: '测试报告',
price: ['1,100', '101,100000'],
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
analysis_dims: ['MARKETOVERVIEW'],
categories: [{ id: '20028', name: '奶粉类目' }],
transaction_channels: ['ALL'],
start_time: '2025-03-01',
end_time: '2026-02-28',
period_type: 'MONTH',
user_name: 'tester@example.com',
payload: {
name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
},
});
});

View File

@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createApp } = require('../src/server');
function createManualPayload() {
return {
reportId: 'report-001',
sourceType: 'MANUAL_CAPTURE',
sourceReportId: null,
aadvid: '1648829117232140',
name: '测试报告',
price: ['1,100', '101,100000'],
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
analysisDims: ['MARKETOVERVIEW'],
categories: [{ id: '20028', name: '奶粉类目' }],
channels: ['ALL'],
startTime: '2025-03-01',
endTime: '2026-02-28',
periodType: 'MONTH',
userName: 'tester@example.com',
payload: {
name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
},
};
}
async function withServer(repository, callback) {
const app = createApp({
repository,
allowedOrigin: 'https://yuntu.oceanengine.com',
});
const server = await new Promise((resolve) => {
const instance = app.listen(0, '127.0.0.1', () => resolve(instance));
});
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await callback(baseUrl);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
test('GET /health returns ok status', async () => {
await withServer(
{
async save() {
throw new Error('save should not be called');
},
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/health`);
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(body, {
success: true,
data: {
status: 'ok',
},
});
},
);
});
test('POST /api/reports returns 400 for invalid auto copy payload', async () => {
await withServer(
{
async save() {
throw new Error('save should not be called');
},
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/api/reports`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
...createManualPayload(),
sourceType: 'AUTO_COPY',
sourceReportId: '',
}),
});
const body = await response.json();
assert.equal(response.status, 400);
assert.equal(body.success, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
},
);
});
test('POST /api/reports persists a valid payload and returns created response', async () => {
let savedRecord = null;
await withServer(
{
async save(record) {
savedRecord = record;
return {
id: 12,
reportId: record.report_id,
created: true,
};
},
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/api/reports`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(createManualPayload()),
});
const body = await response.json();
assert.equal(response.status, 201);
assert.deepEqual(body, {
success: true,
data: {
id: 12,
reportId: 'report-001',
created: true,
},
});
assert.equal(savedRecord.report_id, 'report-001');
assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE');
},
);
});

View File

@ -0,0 +1,134 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const api = require("./yuntuReportFilling.user.js");
test("isTargetCreateReportRequest matches the segmented market create endpoint", () => {
assert.equal(
api.isTargetCreateReportRequest(
"https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140",
"POST",
),
true,
);
assert.equal(
api.isTargetCreateReportRequest(
"https://yuntu.oceanengine.com/product_node/v2/api/industry/insightBrandStats?aadvid=1648829117232140",
"POST",
),
false,
);
});
test("isSupportedPageUrl covers both creation and detail pages", () => {
assert.equal(
api.isSupportedPageUrl(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140",
),
true,
);
assert.equal(
api.isSupportedPageUrl(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/marketOverview?aadvid=1710507483282439&reportId=441518",
),
true,
);
assert.equal(
api.isSupportedPageUrl(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439",
),
false,
);
});
test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => {
assert.deepEqual(api.getLocalApiBaseCandidates(), [
"http://localhost:3000",
"http://127.0.0.1:3000",
]);
});
test("extractReportId returns response.data.reportId when present", () => {
assert.equal(
api.extractReportId({
data: {
reportId: "987654321",
},
}),
"987654321",
);
assert.equal(api.extractReportId({ data: {} }), null);
});
test("shiftDateBackOneYear keeps month/day and clamps leap day", () => {
assert.equal(api.shiftDateBackOneYear("2025-03-01"), "2024-03-01");
assert.equal(api.shiftDateBackOneYear("2024-02-29"), "2023-02-28");
});
test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appends the year range to name", () => {
const original = {
name: "测试(近一年)",
startTime: "2025-03-01",
endTime: "2026-02-28",
nested: {
startTime: "SHOULD_NOT_CHANGE",
},
};
const copied = api.buildAutoCopyPayload(original);
assert.deepEqual(copied, {
name: "测试近一年2024-2025",
startTime: "2024-03-01",
endTime: "2025-02-28",
nested: {
startTime: "SHOULD_NOT_CHANGE",
},
});
assert.notEqual(copied, original);
assert.notEqual(copied.nested, original.nested);
});
test("buildPersistRequest maps manual capture payload into backend request format", () => {
const payload = {
name: "测试",
price: ["1,100", "101,100000"],
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
analysisDims: ["MARKETOVERVIEW"],
categories: [{ id: "20028" }],
channels: ["ALL"],
startTime: "2025-03-01",
endTime: "2026-02-28",
periodType: "MONTH",
userName: "tester@example.com",
};
const request = api.buildPersistRequest({
payload,
aadvid: "1648829117232140",
reportId: "report-001",
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
});
assert.deepEqual(request, {
reportId: "report-001",
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
aadvid: "1648829117232140",
name: "测试",
price: ["1,100", "101,100000"],
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
analysisDims: ["MARKETOVERVIEW"],
categories: [{ id: "20028" }],
channels: ["ALL"],
startTime: "2025-03-01",
endTime: "2026-02-28",
periodType: "MONTH",
userName: "tester@example.com",
payload,
});
});

View File

@ -0,0 +1,716 @@
// ==UserScript==
// @name 云图报告同比复制助手
// @namespace https://yuntu.oceanengine.com/
// @version 0.1.0
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告
// @author wangxi
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @connect localhost
// @connect 127.0.0.1
// ==/UserScript==
(function bootstrap(root, factory) {
const api = factory(root);
if (typeof module === "object" && module.exports) {
module.exports = api;
return;
}
api.init();
})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) {
const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket";
const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"];
const SUPPORTED_PAGE_PATTERNS = [
/^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
/^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/,
];
const STORAGE_KEYS = {
payload: "lastReportPayload",
meta: "lastReportMeta",
};
const EVENT_NAME = "yuntu-report-filling:capture";
const BUTTON_ID = "yuntu-report-filling-action";
const STYLE_ID = "yuntu-report-filling-style";
const INJECT_FLAG = "__yuntuReportFillingHooked__";
const runtimeState = {
isSubmitting: false,
suppressedRequestBody: null,
clearSuppressionTimer: null,
};
function log(message, extra) {
if (extra === undefined) {
console.log("[YuntuReportFilling]", message);
return;
}
console.log("[YuntuReportFilling]", message, extra);
}
function isTargetCreateReportRequest(url, method) {
if (String(method || "").toUpperCase() !== "POST") {
return false;
}
try {
const parsed = new URL(url, root.location && root.location.href ? root.location.href : "https://yuntu.oceanengine.com");
return parsed.pathname === TARGET_PATH && parsed.searchParams.has("aadvid");
} catch (_error) {
return false;
}
}
function isSupportedPageUrl(url) {
try {
const parsed = new URL(
url,
root.location && root.location.href
? root.location.href
: "https://yuntu.oceanengine.com",
);
return SUPPORTED_PAGE_PATTERNS.some((pattern) =>
pattern.test(parsed.pathname),
);
} catch (_error) {
return false;
}
}
function getLocalApiBaseCandidates() {
return [...LOCAL_API_BASES];
}
function extractReportId(responseJson) {
if (!responseJson || typeof responseJson !== "object") {
return null;
}
const reportId = responseJson.data && responseJson.data.reportId;
if (reportId === null || reportId === undefined || reportId === "") {
return null;
}
return String(reportId);
}
function shiftDateBackOneYear(dateString) {
const match = String(dateString).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
throw new Error(`Invalid date string: ${dateString}`);
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const targetYear = year - 1;
const lastDay = new Date(Date.UTC(targetYear, month, 0)).getUTCDate();
const safeDay = Math.min(day, lastDay);
return `${targetYear}-${String(month).padStart(2, "0")}-${String(safeDay).padStart(2, "0")}`;
}
function deepCloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function extractYear(dateString) {
return String(dateString).slice(0, 4);
}
function buildAutoCopyName(originalName, startTime, endTime) {
const baseName = originalName || "";
return `${baseName}${extractYear(startTime)}-${extractYear(endTime)}`;
}
function buildAutoCopyPayload(payload) {
const cloned = deepCloneJson(payload);
cloned.startTime = shiftDateBackOneYear(payload.startTime);
cloned.endTime = shiftDateBackOneYear(payload.endTime);
cloned.name = buildAutoCopyName(payload.name, cloned.startTime, cloned.endTime);
return cloned;
}
function buildPersistRequest({
payload,
aadvid,
reportId,
sourceType,
sourceReportId,
}) {
return {
reportId: String(reportId),
sourceType,
sourceReportId: sourceReportId ? String(sourceReportId) : null,
aadvid: String(aadvid),
name: payload.name || "",
price: Array.isArray(payload.price) ? payload.price : [],
rules: Array.isArray(payload.rules) ? payload.rules : [],
analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [],
categories: Array.isArray(payload.categories) ? payload.categories : [],
channels: Array.isArray(payload.channels) ? payload.channels : [],
startTime: payload.startTime || "",
endTime: payload.endTime || "",
periodType: payload.periodType || null,
userName: payload.userName || null,
payload,
};
}
function parseJson(text) {
return JSON.parse(text);
}
function getAadvidFromUrl(url) {
try {
const parsed = new URL(url, root.location.href);
return parsed.searchParams.get("aadvid");
} catch (_error) {
return null;
}
}
function getCreateRequestUrl(meta) {
if (meta && meta.requestUrl) {
return meta.requestUrl;
}
const aadvid = (meta && meta.aadvid) || getAadvidFromUrl(root.location.href);
if (!aadvid) {
throw new Error("未获取到 aadvid");
}
return `https://yuntu.oceanengine.com${TARGET_PATH}?aadvid=${encodeURIComponent(aadvid)}`;
}
function gmGetValueSafe(key, fallbackValue) {
if (typeof GM_getValue !== "function") {
return fallbackValue;
}
return GM_getValue(key, fallbackValue);
}
function gmSetValueSafe(key, value) {
if (typeof GM_setValue !== "function") {
throw new Error("GM_setValue 不可用");
}
GM_setValue(key, value);
}
function readStoredPayload() {
const rawValue = gmGetValueSafe(STORAGE_KEYS.payload, "");
if (!rawValue) {
return null;
}
return parseJson(rawValue);
}
function readStoredMeta() {
const rawValue = gmGetValueSafe(STORAGE_KEYS.meta, "");
if (!rawValue) {
return null;
}
return parseJson(rawValue);
}
function writeStoredData(payload, meta) {
gmSetValueSafe(STORAGE_KEYS.payload, JSON.stringify(payload));
gmSetValueSafe(STORAGE_KEYS.meta, JSON.stringify(meta));
}
function clearSuppression() {
runtimeState.suppressedRequestBody = null;
if (runtimeState.clearSuppressionTimer) {
root.clearTimeout(runtimeState.clearSuppressionTimer);
runtimeState.clearSuppressionTimer = null;
}
}
function suppressNextCapture(requestBodyText) {
runtimeState.suppressedRequestBody = requestBodyText;
if (runtimeState.clearSuppressionTimer) {
root.clearTimeout(runtimeState.clearSuppressionTimer);
}
runtimeState.clearSuppressionTimer = root.setTimeout(() => {
clearSuppression();
}, 10000);
}
function showToast(message, type) {
const toast = root.document.createElement("div");
toast.className = `yuntu-report-filling-toast is-${type || "info"}`;
toast.textContent = message;
root.document.body.appendChild(toast);
root.requestAnimationFrame(() => {
toast.classList.add("is-visible");
});
root.setTimeout(() => {
toast.classList.remove("is-visible");
root.setTimeout(() => {
toast.remove();
}, 240);
}, 2200);
}
function updateButtonState() {
const button = root.document.getElementById(BUTTON_ID);
if (!button) {
return;
}
const hasPayload = Boolean(readStoredPayload());
button.disabled = runtimeState.isSubmitting || !hasPayload;
button.textContent = runtimeState.isSubmitting
? "创建中..."
: "一键复制同比报告";
button.dataset.enabled = String(hasPayload);
}
async function persistToLocalServer(payload) {
const requestBody = JSON.stringify(payload);
const bases = getLocalApiBaseCandidates();
let lastError = null;
for (const base of bases) {
try {
if (typeof GM_xmlhttpRequest !== "function") {
const response = await fetch(`${base}/api/reports`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: requestBody,
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error((data.error && data.error.message) || "本地服务请求失败");
}
return data;
}
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: `${base}/api/reports`,
headers: {
"Content-Type": "application/json",
},
data: requestBody,
onload(response) {
try {
const json = parseJson(response.responseText);
if (response.status < 200 || response.status >= 300 || !json.success) {
reject(new Error((json.error && json.error.message) || "本地服务请求失败"));
return;
}
resolve(json);
} catch (error) {
reject(error);
}
},
onerror() {
reject(new Error(`无法连接本地服务: ${base}`));
},
});
});
return data;
} catch (error) {
lastError = error;
}
}
throw new Error(
`本地服务未启动,请先在 server 目录执行 npm start。${lastError ? ` 原始错误: ${lastError.message}` : ""}`,
);
}
async function createReportThroughPage(url, payload) {
const pageFetch =
typeof unsafeWindow !== "undefined" && unsafeWindow.fetch
? unsafeWindow.fetch.bind(unsafeWindow)
: root.fetch.bind(root);
const requestBodyText = JSON.stringify(payload);
suppressNextCapture(requestBodyText);
const response = await pageFetch(url, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
},
credentials: "include",
body: requestBodyText,
});
const data = await response.json();
if (!response.ok) {
clearSuppression();
throw new Error((data && data.message) || `创建报告失败: ${response.status}`);
}
return {
responseJson: data,
requestBodyText,
};
}
async function handleButtonClick() {
if (runtimeState.isSubmitting) {
return;
}
const payload = readStoredPayload();
const meta = readStoredMeta();
if (!payload || !meta) {
updateButtonState();
showToast("请先手动成功创建一次报告", "error");
return;
}
runtimeState.isSubmitting = true;
updateButtonState();
try {
const nextPayload = buildAutoCopyPayload(payload);
const requestUrl = getCreateRequestUrl(meta);
const sourceReportId = meta.reportId || null;
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
const reportId = extractReportId(responseJson);
if (!reportId) {
throw new Error("创建成功,但未获取到 reportId");
}
const nextMeta = {
reportId,
aadvid: meta.aadvid || getAadvidFromUrl(requestUrl),
sourceType: "AUTO_COPY",
capturedAt: new Date().toISOString(),
requestUrl,
};
writeStoredData(nextPayload, nextMeta);
await persistToLocalServer(
buildPersistRequest({
payload: nextPayload,
aadvid: nextMeta.aadvid,
reportId,
sourceType: "AUTO_COPY",
sourceReportId,
}),
);
showToast(`同比报告创建成功:${reportId}`, "success");
} catch (error) {
log("自动创建同比报告失败", error);
showToast(error.message || "同比报告创建失败", "error");
} finally {
runtimeState.isSubmitting = false;
updateButtonState();
}
}
async function handleCapturedRequest(detail) {
if (!detail || !detail.requestBody || !detail.responseText) {
return;
}
if (runtimeState.suppressedRequestBody && runtimeState.suppressedRequestBody === detail.requestBody) {
clearSuppression();
return;
}
try {
const payload = parseJson(detail.requestBody);
const responseJson = parseJson(detail.responseText);
const reportId = extractReportId(responseJson);
if (!reportId) {
return;
}
const aadvid = getAadvidFromUrl(detail.url) || getAadvidFromUrl(root.location.href);
if (!aadvid) {
throw new Error("未从请求中识别到 aadvid");
}
const meta = {
reportId,
aadvid,
sourceType: "MANUAL_CAPTURE",
capturedAt: new Date().toISOString(),
requestUrl: detail.url,
};
writeStoredData(payload, meta);
updateButtonState();
await persistToLocalServer(
buildPersistRequest({
payload,
aadvid,
reportId,
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
}),
);
showToast(`已记录报告配置:${reportId}`, "success");
} catch (error) {
log("记录手动创建报告失败", error);
showToast(error.message || "记录报告配置失败", "error");
updateButtonState();
}
}
function ensureStyles() {
if (root.document.getElementById(STYLE_ID)) {
return;
}
const css = `
#${BUTTON_ID} {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 99999;
width: 148px;
min-height: 48px;
padding: 10px 14px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #0f766e, #115e59);
color: #ffffff;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
cursor: pointer;
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
}
#${BUTTON_ID}:hover:not(:disabled) {
transform: translateY(-50%) translateX(-2px);
box-shadow: 0 14px 32px rgba(15, 118, 110, 0.34);
}
#${BUTTON_ID}:disabled {
cursor: not-allowed;
opacity: 0.52;
box-shadow: none;
}
.yuntu-report-filling-toast {
position: fixed;
top: 24px;
right: 24px;
z-index: 100000;
max-width: 360px;
padding: 12px 16px;
border-radius: 12px;
color: #ffffff;
font-size: 13px;
line-height: 1.5;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
opacity: 0;
transform: translateY(-8px);
transition: opacity 180ms ease, transform 180ms ease;
}
.yuntu-report-filling-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.yuntu-report-filling-toast.is-success {
background: #166534;
}
.yuntu-report-filling-toast.is-error {
background: #b91c1c;
}
.yuntu-report-filling-toast.is-info {
background: #1d4ed8;
}
`;
if (typeof GM_addStyle === "function") {
GM_addStyle(css);
return;
}
const style = root.document.createElement("style");
style.id = STYLE_ID;
style.textContent = css;
root.document.head.appendChild(style);
}
function ensureButton() {
if (root.document.getElementById(BUTTON_ID)) {
return;
}
const button = root.document.createElement("button");
button.id = BUTTON_ID;
button.type = "button";
button.textContent = "一键复制同比报告";
button.addEventListener("click", handleButtonClick);
root.document.body.appendChild(button);
updateButtonState();
}
function injectCaptureHook() {
if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") {
return;
}
const script = root.document.createElement("script");
script.textContent = `
(() => {
if (window.${INJECT_FLAG}) {
return;
}
window.${INJECT_FLAG} = true;
const eventName = ${JSON.stringify(EVENT_NAME)};
const targetPath = ${JSON.stringify(TARGET_PATH)};
const isTarget = (url, method) => {
try {
const parsed = new URL(url, window.location.href);
return String(method || "").toUpperCase() === "POST"
&& parsed.pathname === targetPath
&& parsed.searchParams.has("aadvid");
} catch (_error) {
return false;
}
};
const serializeBody = (body) => {
if (typeof body === "string") {
return body;
}
if (!body) {
return "";
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (body instanceof FormData) {
return "";
}
try {
return JSON.stringify(body);
} catch (_error) {
return "";
}
};
const dispatchCapture = (detail) => {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
};
const originalFetch = window.fetch;
window.fetch = async function patchedFetch(input, init) {
const requestUrl = input instanceof Request ? input.url : String(input);
const method = (init && init.method) || (input instanceof Request ? input.method : "GET");
const requestBody = init && "body" in init ? serializeBody(init.body) : "";
const response = await originalFetch.apply(this, arguments);
if (isTarget(requestUrl, method)) {
try {
const cloned = response.clone();
const responseText = await cloned.text();
dispatchCapture({
url: requestUrl,
method,
requestBody,
responseText,
status: response.status,
});
} catch (_error) {}
}
return response;
};
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__yuntuReportMethod = method;
this.__yuntuReportUrl = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function patchedSend(body) {
this.__yuntuReportBody = serializeBody(body);
this.addEventListener("loadend", () => {
if (!isTarget(this.__yuntuReportUrl, this.__yuntuReportMethod)) {
return;
}
dispatchCapture({
url: this.__yuntuReportUrl,
method: this.__yuntuReportMethod,
requestBody: this.__yuntuReportBody || "",
responseText: typeof this.responseText === "string" ? this.responseText : "",
status: this.status,
});
}, { once: true });
return originalSend.apply(this, arguments);
};
})();
`;
root.document.documentElement.appendChild(script);
script.remove();
root.document.documentElement.setAttribute(INJECT_FLAG, "true");
}
function attachCaptureListener() {
root.addEventListener(EVENT_NAME, (event) => {
handleCapturedRequest(event.detail);
});
}
function init() {
if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) {
return;
}
if (!root.document || !root.document.body) {
root.addEventListener("DOMContentLoaded", init, { once: true });
return;
}
ensureStyles();
ensureButton();
attachCaptureListener();
injectCaptureHook();
updateButtonState();
}
return {
buildAutoCopyPayload,
buildAutoCopyName,
buildPersistRequest,
extractReportId,
getLocalApiBaseCandidates,
init,
isSupportedPageUrl,
isTargetCreateReportRequest,
shiftDateBackOneYear,
};
});