# 数据清洗脚本说明

`@apidance/mcp` 会把公共数据清洗脚本打包在 `public-scripts/data-cleaning/` 目录中。

这些脚本只负责清洗已经采集好的 Apidance Twitter API JSON，不会自己请求接口，也不需要 API key。

## MCP Resources

支持 MCP resources 直接读取说明和脚本源码：

| Resource URI | 说明 |
| --- | --- |
| `apidance://twitter/data-cleaning` | 英文清洗说明。 |
| `apidance://twitter/data-cleaning.zh-CN` | 中文清洗说明。 |
| `apidance://twitter/scripts/clean-user-tweets.mjs` | 可直接运行的 token / 用户时间线清洗脚本。 |
| `apidance://twitter/scripts/twitter-normalize.mjs` | 可 import 的 Twitter GraphQL 归一化工具库。 |

## `clean-user-tweets.mjs`

路径：

```text
public-scripts/data-cleaning/clean-user-tweets.mjs
```

这个脚本适合清洗“按用户聚合后的推文时间线数据”，输出轻量 JSON，方便做 token 时间线、用户列表、看板或后续分析。

常见输入数据来源：

| Endpoint id | Apidance path | 用途 |
| --- | --- | --- |
| `graphql_search_timeline` | `GET /graphql/SearchTimeline` | 搜索 token/cashtag 相关推文，发现候选用户。 |
| `graphql_user_tweets_and_replies` | `GET /graphql/UserTweetsAndReplies` | 采集某个候选用户的推文和回复时间线，通常是主输入来源。 |
| `graphql_user_tweets` | `GET /graphql/UserTweets` | 只采集用户原创/主页推文时可用。 |
| `graphql_user_media` | `GET /graphql/UserMedia` | 采集用户媒体推文时可用。 |
| `graphql_tweet_detail` | `GET /graphql/TweetDetail` | 补充回复、引用、文章推文等上下文。 |
| `graphql_tweet_result_by_rest_id` | `GET /graphql/TweetResultByRestId` | 补充单条推文上下文。 |
| `custom_api_endpoint` | `GET /graphql/{id}/{path}` | 可用于自定义 GraphQL，例如批量 `TweetResultsByRestIds`。 |

脚本期望输入是一个聚合 JSON 文件，支持下面几类结构：

```json
{
  "query": {
    "token_symbol": "CASHCAT"
  },
  "users": [
    {
      "user": {},
      "target_token_mentions": {
        "mention_count": 3
      },
      "pages_fetched": 10,
      "tweets": []
    }
  ]
}
```

也兼容：

- `data.users`
- `timelines`
- 每个用户条目里的 `tweets`
- `timeline`
- `user_tweets`
- `data.tweets`

运行示例：

```bash
node public-scripts/data-cleaning/clean-user-tweets.mjs \
  --symbol CASHCAT \
  --input CASHCAT/cashcat_candidate_user_timelines.json \
  --output-dir CASHCAT \
  --public-data-path CASHCAT
```

可以额外传入时间窗口：

```bash
node public-scripts/data-cleaning/clean-user-tweets.mjs \
  --symbol CASHCAT \
  --since 1782835200 \
  --until 1783353600
```

输出文件：

| 文件 | 说明 |
| --- | --- |
| `<output-dir>/<prefix>_user_tweets_light.json` | 合并后的轻量用户推文 JSON。 |
| `<output-dir>/tweets/<user_id>.json` | 每个用户一份清洗后的推文文件。 |
| `<output-dir>/tweets/index.json` | manifest，适合前端看板加载和排序。 |

清洗后的 tweet 主要字段：

```json
{
  "id": "tweet id",
  "url": "https://x.com/user/status/id",
  "created_at_iso": "2026-01-01T00:00:00.000Z",
  "created_at_ms": 1767225600000,
  "type": "tweet",
  "text": "tweet text",
  "author": {
    "id": "user id",
    "screen_name": "handle",
    "name": "display name",
    "description": "",
    "followers_count": 0,
    "profile_image_url_https": "",
    "url": ""
  },
  "metrics": {
    "favorite_count": 0,
    "reply_count": 0,
    "retweet_count": 0,
    "quote_count": 0,
    "view_count": 0
  },
  "entities": {
    "symbols": ["TOKEN"],
    "hashtags": [],
    "mentions": [],
    "contract_addresses": []
  },
  "tokens": ["TOKEN"],
  "contracts": ["0x..."],
  "has_token": true,
  "has_contract": true,
  "context": {
    "reply_to": null,
    "quoted": null,
    "retweeted": null
  }
}
```

每个用户文件主要字段：

```json
{
  "schema_version": "1.0",
  "generated_at": "2026-01-01T00:00:00.000Z",
  "query": {
    "token_symbol": "CASHCAT",
    "since_time": 1782835200,
    "until_time": 1783353600
  },
  "user": {},
  "target_token_mentions": {},
  "pages_fetched": 10,
  "stats": {
    "tweet_count": 0,
    "tweets_with_token_count": 0,
    "tweets_with_contract_count": 0,
    "token_count": 0,
    "contract_count": 0
  },
  "token_summary": [],
  "contract_summary": [],
  "tweets": []
}
```

## `twitter-normalize.mjs`

路径：

```text
public-scripts/data-cleaning/twitter-normalize.mjs
```

这个文件是 ESM 工具库，适合在自己的 Node 脚本里 import，用来从 Apidance 原始 GraphQL response 中提取并归一化用户、推文和 cursor。

常见输入接口：

| Endpoint id | Apidance path | 常用导出 |
| --- | --- | --- |
| `graphql_user_by_screen_name` | `GET /graphql/UserByScreenName` | `extractUsers`, `normalizeUser` |
| `graphql_user_by_rest_id` | `GET /graphql/UserByRestId` | `extractUsers`, `normalizeUser` |
| `graphql_user_business_profile_team_timeline` | `GET /graphql/UserBusinessProfileTeamTimeline` | `extractUsers`, `extractBottomCursor` |
| `graphql_following` | `GET /graphql/Following` | `extractUsers`, `extractBottomCursor` |
| `graphql_followers` | `GET /graphql/Followers` | `extractUsers`, `extractBottomCursor` |
| `graphql_search_timeline` | `GET /graphql/SearchTimeline` | `extractTweets`, `extractUsers`, `extractBottomCursor` |
| `graphql_user_tweets_and_replies` | `GET /graphql/UserTweetsAndReplies` | `extractTweets`, `extractBottomCursor` |
| `graphql_tweet_detail` | `GET /graphql/TweetDetail` | `extractTweets`, `extractUsers`, `extractBottomCursor` |
| `graphql_tweet_result_by_rest_id` | `GET /graphql/TweetResultByRestId` | `normalizeTweet`, `extractTweets` |
| `graphql_community_tweets_timeline` | `GET /graphql/CommunityTweetsTimeline` | `extractTweets`, `extractUsers`, `extractBottomCursor` |

使用示例：

```js
import fs from "node:fs/promises";
import { extractBottomCursor, extractTweets, extractUsers } from "./public-scripts/data-cleaning/twitter-normalize.mjs";

const response = JSON.parse(await fs.readFile("search-page.json", "utf8"));
const users = extractUsers(response);
const tweets = extractTweets(response);
const cursor = extractBottomCursor(response);
```

导出函数：

| 导出 | 说明 |
| --- | --- |
| `walk(root, visitor)` | 遍历 JSON 树里的所有对象/数组。 |
| `extractBottomCursor(root)` | 提取最后一个 Bottom timeline cursor。 |
| `normalizeUser(userLike)` | 归一化单个用户对象，失败返回 `null`。 |
| `extractUsers(root)` | 从完整 response 中提取并去重用户。 |
| `normalizeTweet(tweetLike)` | 归一化单条推文，失败返回 `null`。 |
| `extractTweets(root)` | 从完整 response 中提取并去重推文，包括 reply/quote/retweet 上下文。 |

归一化 user 会包含账号 id、handle、昵称、简介、粉丝数、认证状态、头像、banner、专业账号信息、商业附属信息等字段。

归一化 tweet 会包含 tweet id、URL、时间、类型、正文、语言、会话/回复/引用/转推 id、作者、指标、entities、合约地址和上下文推文。
