Files
StudioLift/docs/yt-studio-export-principle.md

231 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# YouTube Studio 高级分析「导出当前视图」原理
> 技术原理说明 · 实测分析
>
> 从点击「导出当前视图 → 逗号分隔值 (.csv)」到 ZIP 落盘,拆解一次完整的请求-响应机制:后端并不生成可下载的 URL而是把打包好的 ZIP 以 base64 内联在 API 响应里,由前端解码成 Blob 后触发浏览器下载。
>
> 抓包日期2026-08-21 · 方法:浏览器抓包 + 脚本复现
## 目录
1. [概述与核心结论](#01-概述与核心结论)
2. [用户操作入口](#02-用户操作入口)
3. [网络请求机制](#03-网络请求机制)
4. [exportQuery 请求体结构](#04-exportquery-请求体结构)
5. [服务端响应与编码](#05-服务端响应与编码)
6. [ZIP 内容结构](#06-zip-内容结构)
7. [客户端解码与下载](#07-客户端解码与下载)
8. [文件命名规则](#08-文件命名规则)
9. [落盘与重名去重](#09-落盘与重名去重)
10. [拦截与自动化可行性](#10-拦截与自动化可行性)
11. [端到端流程总览](#11-端到端流程总览)
---
## 01 概述与核心结论
「高级分析」是 YouTube Studio内容管理器面向版权所有者与频道的一份数据分析视图。页面上方有一个「导出当前视图」按钮下拉后选择 `逗号分隔值 (.csv)`,即可把当前可见的表格导出为一份本地文件 —— 用户拿到手的并非多个散落的 CSV而是一个 ZIP 压缩包。
这份导出的关键在于:**整个文件并没有走传统的「服务端生成下载链接 → 浏览器 GET 下载」路径**。在 2026-08-21 的抓包中,页面向后端发了一次 `POST` 请求,响应体里直接内联了一段 base64 字符串,其解码结果是完整的 ZIP 字节流。前端拿到这段字符串后自行解码、构造 `Blob`,再唤起浏览器的下载动作。整个过程没有出现任何指向下载文件的 GET 请求或页面跳转。
> **一句话结论**
>
> 导出 = **前端发起 POST** → **后端打包 ZIP 并 base64 内联返回** → **前端解码为 Blob 触发下载**。因为没有独立下载 URL反而使「拦截响应、自行解码落盘」成为完全可行的自动化方式。
---
## 02 用户操作入口
操作发生在内容管理器的「高级分析 / Explore」页面URL 以 `studio.youtube.com/owner/…/analytics` 形态出现)。页面通过「维度」按钮切换统计口径(如**内容**、**频道**等),再通过时间选择器确定日期范围。
页面的「导出当前视图」下拉提供多种格式,本主题只涉及 `逗号分隔值 (.csv)`。点击该选项后,前端读取当前维度和日期范围,把它转成一份结构化的命名查询,随后发起网络请求。用户视角只有一次点击,但背后是下述完整链路。
---
## 03 网络请求机制
导出动作返回的是一个内嵌压缩包的 JSON API 调用,而不是文件下载请求。抓包得到的确切端点如下:
```
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
```
这是 YouTube 内部 `youtubei` 风格 v1 接口的一个方法,`yta_web` 对应 YouTube Analytics 的 Web 端命名空间,`csv_export` 即「导出 CSV实为 ZIP」的动作。`?alt=json` 表示要求返回 JSON 而非其他编码。请求体承载一份 `exportQuery`,描述「要导出哪些统计维度和哪段日期」。
---
## 04 exportQuery 请求体结构
请求体POST body的根字段是 `exportQuery`。它内部通过一个 `joinRequest` 描述导出内容,核心是 `nodes` 数组 —— 每个节点对应导出一张表格。实测一次导出包含三张表:**表格数据**、**图表数据**、**总计**。每个节点内部的 `value.query` 声明了维度类型和日期范围。
```json
{
"exportQuery": {
"joinRequest": {
"nodes": [
{ "value": { "query": {
"dimensions": [{ "type": "VIDEO" }], // 表格数据
"timeRange": { "dateIdRange": {
"inclusiveStart": 20260723,
"exclusiveEnd": 20260820
} }
} } },
{ "value": { "query": { /* 图表数据 */ } } },
{ "value": { "query": { /* 总计 */ } } }
]
}
}
}
```
其中两处日期字段最值得留意:`inclusiveStart` 表述导出区间的起始日(含),`exclusiveEnd` 表述结束日(不含,即「到这一天为止」)。两者都是 `YYYYMMDD` 格式的整数,例如 `20260723` 表示 2026-07-23。它们同时决定了导出内容和最终文件名。
> **关键词义**
>
> `exclusiveEnd` 是「开区间上界」:导出数据覆盖到 exclusiveEnd 的前一天。文件名里二者的下划线形式正是从这两个字段直接格式化而来。
---
## 05 服务端响应与编码
后端收到 `exportQuery` 后,把请求的三张表各自输出成 CSV再打包成一个 ZIP然后把 ZIP 的**二进制字节流做 base64 编码**,塞进 JSON 响应的一个重要字段里返回:
```json
{
"responseContext": { /* 服务元信息 */ },
"zippedData": "UEsDBBQAAAAA..." // base64 编码的 ZIP 字节流
}
```
`zippedData` 是这条响应的精华。它开头的 `UEsDBBQ` 是 base64 对 ZIP 文件头魔数 `PK\x03\x04`(即 ASCII 的 `PK` 两个字节)的编码 —— 这是识别「这是一个 ZIP」的最直接证据。JSON 只能承载文本,二进制 ZIP 无法原样嵌入,因此后端先 base64 编码,让整个压缩包变成一段纯文本字符串随响应返回。
这里没有生成任何 `/download/…` 之类的文件地址,也没有 `Content-Disposition: attachment` 的响应头来驱动浏览器另存 —— 下载的编排完全交还给了前端。
---
## 06 ZIP 内容结构
`zippedData` 做一次 base64 解码,得到的是一个标准 ZIP 压缩包,内含三个 CSV 文件,正好与请求体 `joinRequest.nodes` 的三个节点一一对应:
| ZIP 内文件名 | 对应请求节点 | 说明 |
| --- | --- | --- |
| `表格数据.csv` | nodes[0] | 当前维度下的逐条明细(如每条内容/频道的指标) |
| `图表数据.csv` | nodes[1] | 供图表渲染的时间序列 / 汇总数据 |
| `总计.csv` | nodes[2] | 全表合计行的汇总值 |
三张表的拆分说明导出本质上是一次「多表联查」:前端页面上同时呈现的明细表、图表和合计,被后端一次性打包进同一个压缩包交付,而不是三次独立下载。
---
## 07 客户端解码与下载
前端收到 JSON 后做的是标准的「base64 → 二进制 → Blob → 触发下载」四步。由于抓包时没有观察到任何指向下载文件的 GET 或跳转,可以判定这是**客户端 Blob 下载**而非服务端重定向下载。下面这段是这一机制的通用实现示意(并非从 YouTube 混淆后的前端源码中直接提取):
```javascript
// base64 → 字节数组
const bytes = atob(json.zippedData);
const buf = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) buf[i] = bytes.charCodeAt(i);
// 字节 → Blob → 对象 URL
const blob = new Blob([buf], { type: "application/zip" });
const url = URL.createObjectURL(blob);
// 挂到 <a> 上并触发点击,交由浏览器下载管理器接管
const a = document.createElement("a");
a.href = url;
a.download = "内容 2026-07-23_2026-08-20 WL Media.zip";
a.click();
URL.revokeObjectURL(url);
```
`a.download` 里填写的文件名,就是用户在下载管理器里看到的名字。而真正的写盘动作,由浏览器自身的下载管理器执行。
---
## 08 文件命名规则
与实测下载目录 `D:\Downloads` 中的产物一致,最终文件名遵循固定模板:
```
<维度标签> <inclusiveStart>_<exclusiveEnd> <账号名>.zip
```
其中 `inclusiveStart``exclusiveEnd` 取自请求体,由 `YYYYMMDD` 整数格式化为 `YYYY-MM-DD`。实测的一个完整例子是:
```
内容 2026-07-23_2026-08-20 WL Media.zip
```
维度标签来自「维度」选择器:类型 `VIDEO` 对应「内容」、`USER` 对应「频道」。账号名取自右上角账号选择器按钮的文本(示例为 `WL Media`)。
| 构成部分 | 来源 | 示例值 |
| --- | --- | --- |
| 维度标签 | 页面「维度」按钮VIDEO→内容 / USER→频道 | 内容 |
| 起始日 | `inclusiveStart` 格式化为 YYYY-MM-DD | 2026-07-23 |
| 结束日 | `exclusiveEnd` 格式化为 YYYY-MM-DD | 2026-08-20 |
| 账号名 | 右上角账号按钮文本 | WL Media |
---
## 09 落盘与重名去重
因为下载由浏览器下载管理器接管,落盘位置默认是浏览器的下载目录(本机为 `D:\Downloads`且无需「另存为」确认。Chromium 内核对重名文件会自动追加序号后缀,这正是「需求文件.zip → 需求文件 (1).zip → 需求文件 (2).zip」这一规则的由来。
去重逻辑是:若目标名已存在,则在扩展名前追加 ` (n)``n` 从 1 开始递增,每次取「最小不冲突的序号」—— 即便 `(1)` 已被占用,也会继续向后找 `(2)`,而不是覆盖或失败。这一规则在后续的自动化脚本中被原样复现,从而做到完全不依赖浏览器也能自管文件名。
> **去重规则**
>
> 同名文件依次保存为 `需求文件.zip`、`需求文件 (1).zip`、`需求文件 (2).zip`……若某个 `(n)` 已存在则跳到下一个可用 n。
---
## 10 拦截与自动化可行性
正因为导出所需的全部数据ZIP 字节流)都内联在一次 API 响应里,且不依赖额外的文件下载地址,**在响应层拦截即可完整掌控落盘行为** —— 保存目录、文件名、重名去重全由脚本决定,绕开浏览器的下载管理器和确认弹窗。
实现上有两条路径,落地为一套脚本与一个可复用 skill
- **响应拦截 + 自行解码**:命中 `csv_export` 响应后读取 `zippedData`base64 解码、反推文件名、去重后写盘,完全可控。
- **依赖 Chromium 下载偏好**:开启自动下载与内置去重,让浏览器原样落盘,仅省去人工点击。
无论哪条路径,前提都是复用**已登录 YouTube Studio** 的浏览器会话:要么以已登录的用户数据目录启动浏览器,要么通过调试端口附加到已打开的浏览器。否则页面会跳转到 Google 登录页,导出无法触发。
---
## 11 端到端流程总览
```mermaid
flowchart TD
A[1. 操作入口<br/>点击「导出当前视图 → 逗号分隔值 (.csv)」] --> B
B[2. 组装请求<br/>前端生成 exportQueryjoinRequest 节点 + 日期范围] --> C
C[3. 发送请求<br/>POST /youtubei/v1/yta_web/csv_export?alt=json] --> D
D[4. 服务端打包<br/>三张 CSV 压缩为 ZIP再 base64 编码] --> E
E[5. 内联返回<br/>响应体 zippedData 承载 base64PK 魔数开头)] --> F
F[6. 前端解码<br/>base64 → Blob触发浏览器下载] --> G
G[7. 落盘去重<br/>下载管理器写入默认目录,重名自动加 n]
style A fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style B fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style C fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style D fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style E fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style F fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style G fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
```
图 1 · 从点击导出到 ZIP 落盘的端到端流程(红色 = 客户端/前端,蓝色 = YouTube 后端)
**请求 / 响应结构映射**
| 请求 · exportQuery | 响应 · zippedData |
| --- | --- |
| `joinRequest.nodes`3 张表) | `responseContext`(元信息) |
| `dimensions` → 维度类型 | `zippedData` = base64(ZIP) |
| `dateIdRange.inclusiveStart` | 开头 `UEsDBBQ` = PK 魔数 |
| `dateIdRange.exclusiveEnd` | 解码得 表格/图表/总计.csv |
| 日期均为 YYYYMMDD 整数 | — |
---
*分析基于 2026-08-21 的浏览器抓包与脚本复现实证。配套脚本:`youtube-studio-csv-download`*