TAPD 需求获取脚本使用指南
背景与目的
在日常开发中,经常需要查阅 TAPD 上的需求详情来辅助编码、评审或生成任务说明。手动打开浏览器、登录、搜索的流程繁琐,且无法与 AI 编程工具(如 Claude Code)直接集成。
本方案的目标是:让开发者或 AI 工具能在命令行中直接读取 TAPD 需求,无需打开浏览器,从而:
- 在 AI 辅助开发时,直接把需求内容喂给 AI,减少人工粘贴
- 快速查询某个需求的状态、负责人、所属迭代
- 脚本化地批量处理需求数据(如导出、统计等)
技术方案
为什么不用 TAPD 官方 API
TAPD 提供了 开放 API,但需要申请 API Token,且权限申请流程繁琐,需要管理员审批。
实际采用的方案:逆向前端内部 API + 自动登录
通过分析 TAPD 前端 JS bundle,发现其 React SPA 调用了一套未公开文档的内部 REST API。本脚本复现了以下两个关键步骤:
1. 自动登录(绕过 CAPTCHA)
TAPD 登录页面的 JS 代码在提交表单前,会用 AES-256-CBC + ZeroPadding 对密码加密,并将加密用的 key 和 iv 与密文一起提交给服务器(服务器用收到的 key/iv 解密,而非共享密钥)。
由于这套加密逻辑完全在浏览器端执行且不依赖 CAPTCHA,脚本用 Node.js 内置 crypto 模块复现后,可以直接用账号密码完成登录,不触发图形验证码。登录成功后将 session cookie 持久化到本地文件,后续请求复用,避免每次都重新登录。
2. 调用内部 API 读取数据
通过分析 static-fe.tapd.cn/chunk-common.*.js 中的接口调用代码,找到了以下可用的内部 API:
| 接口 | 方法 | 说明 |
|---|---|---|
/api/workspace/workspaces/get_all_my_projects |
GET | 获取我的项目列表 |
/api/entity/stories/story_list_by_condition |
POST | 查询需求列表(支持关键词、分页) |
/api/entity/stories/stories/get_info |
GET | 获取需求详情 |
/api/entity/bugs/bug_list_by_condition |
POST | 查询 Bug 列表 |
所有请求需携带登录 cookie 并设置 X-Requested-With: XMLHttpRequest 头。
整体流程:
账号密码
↓ AES-256-CBC 加密密码
POST /cloud_logins/login
↓ 跟踪重定向链(SSO 多跳)
获得完整 session cookie → 保存到 ~/.claude/tapd-cookie.txt
↓
POST /api/entity/stories/story_list_by_condition
↓
返回 JSON 格式的需求列表
运行环境
- Node.js 18+(使用内置
fetch和crypto,无需安装任何依赖) - 脚本存放在
~/.claude/tapd.mjs(用户级,跨项目复用)
安装配置
前置要求
确认 Node.js 版本 ≥ 18(脚本使用内置 fetch 和 crypto,无需安装任何 npm 包):
node -v # 需要 v18.0.0 以上
# fnm
fnm install 20
fnm use 20
# nvm
nvm install 20
nvm use 20
1. 创建目录和脚本文件
# 创建目录(如果不存在)
mkdir -p ~/.claude
# 创建脚本文件(将下方完整代码粘贴进去)
nano ~/.claude/tapd.mjs
# 或用 VS Code 打开
code ~/.claude/tapd.mjs
将以下内容保存为 ~/.claude/tapd.mjs:
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { randomBytes, createCipheriv } from 'crypto';
const COOKIE_FILE = join(homedir(), '.claude', 'tapd-cookie.txt');
const DEFAULT_WS = '67139335'; // LinkMed 项目
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const jar = {};
function loadCookie() {
if (existsSync(COOKIE_FILE)) {
for (const kv of readFileSync(COOKIE_FILE, 'utf8').trim().split('; ')) {
const [k, ...v] = kv.split('=');
if (k) jar[k.trim()] = v.join('=').trim();
}
}
}
function saveCookie() {
writeFileSync(COOKIE_FILE, Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '));
}
function getCookieStr() { return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '); }
function parseCookies(resp) {
for (const c of (resp.headers.getSetCookie?.() || [])) {
const eqIdx = c.indexOf('=');
const key = c.slice(0, eqIdx).trim();
const val = c.slice(eqIdx + 1).split(';')[0].trim();
if (val && val !== 'deleted') jar[key] = val;
else delete jar[key];
}
}
async function req(url, opts = {}) {
const resp = await fetch(url, {
...opts,
headers: { 'User-Agent': UA, 'Cookie': getCookieStr(), ...(opts.headers || {}) },
redirect: 'manual',
});
parseCookies(resp);
return resp;
}
async function get(path) {
const r = await req(`https://www.tapd.cn${path}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*' }
});
return r.json();
}
async function post(path, body, wsId = DEFAULT_WS) {
const r = await req(`https://www.tapd.cn${path}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*',
'Content-Type': 'application/json',
Referer: `https://www.tapd.cn/tapd_fe/${wsId}/prong/stories`,
Origin: 'https://www.tapd.cn',
},
body: JSON.stringify(body),
});
return r.json();
}
function aesEncrypt(password) {
const key = randomBytes(32), iv = randomBytes(16);
const buf = Buffer.from(password, 'utf8');
const pad = 16 - (buf.length % 16);
const padded = pad === 16 ? buf : Buffer.concat([buf, Buffer.alloc(pad, 0)]);
const cipher = createCipheriv('aes-256-cbc', key, iv);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return { ciphertext: encrypted.toString('base64'), key: key.toString('base64'), iv: iv.toString('base64') };
}
async function login(email, password) {
await req('https://www.tapd.cn/cloud_logins/login?site=TAPD&ref=https%3A%2F%2Fwww.tapd.cn%2F');
const enc = aesEncrypt(password);
const form = new URLSearchParams({
'data[Login][email]': email, 'data[Login][password]': enc.ciphertext,
'data[Login][encrypt_key]': enc.key, 'data[Login][encrypt_iv]': enc.iv,
'data[Login][via]': 'encrypt_password', 'data[Login][type]': '2',
'data[Login][ref]': 'https://www.tapd.cn/', 'data[Login][site]': 'TAPD',
'data[Login][login]': 'login', 'data[protocol]': '1',
});
const p2 = await req('https://www.tapd.cn/cloud_logins/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://www.tapd.cn', Referer: 'https://www.tapd.cn/cloud_logins/login' },
body: form.toString(),
});
let location = p2.headers.get('location');
while (location) {
const r = await req(location);
location = r.headers.get('location');
}
saveCookie();
return !!jar['_wt'];
}
const [,, cmd, arg1, arg2] = process.argv;
loadCookie();
if (cmd === 'login') {
const email = arg1 || 'your@email.com';
const password = arg2 || 'yourpassword';
process.stderr.write(`Logging in as ${email}...\n`);
const ok = await login(email, password);
console.log(ok ? 'Login successful' : 'Login failed');
} else if (cmd === 'projects') {
const data = await get('/api/workspace/workspaces/get_all_my_projects');
const projects = data.data?.all_my_projects || [];
projects.forEach(p => console.log(`${p.id}\t${p.project_name}\t${p.status}`));
} else if (cmd === 'stories') {
const wsId = arg1 || DEFAULT_WS;
const keyword = arg2 || '';
const body = { workspace_ids: [wsId], page: 1, page_count: 20 };
if (keyword) body.search_data = { keyword };
const data = await post('/api/entity/stories/story_list_by_condition', body, wsId);
if (data.meta?.code && String(data.meta.code) !== '0') {
console.error('Error:', data.meta.message);
process.exit(1);
}
const list = data.data?.list || [];
console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
list.forEach(s => console.log(`[${s.short_id}] ${s.name}\n status=${s.status} owner=${s.owner} iteration=${s.iteration_name}`));
} else if (cmd === 'story') {
const storyId = arg1;
if (!storyId) { console.error('Usage: tapd.mjs story <story_id>'); process.exit(1); }
const wsId = arg2 || DEFAULT_WS;
const data = await get(`/api/entity/stories/stories/get_info?workspace_id=${wsId}&story_id=${storyId}`);
console.log(JSON.stringify(data, null, 2));
} else if (cmd === 'bugs') {
const wsId = arg1 || DEFAULT_WS;
const data = await post('/api/entity/bugs/bug_list_by_condition', { workspace_ids: [wsId], page: 1, page_count: 20 }, wsId);
const list = data.data?.list || [];
console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
list.forEach(b => console.log(`[${b.id}] ${b.title}\n status=${b.status} owner=${b.owner}`));
} else {
console.log('Usage:');
console.log(' node ~/.claude/tapd.mjs login <email> <password>');
console.log(' node ~/.claude/tapd.mjs projects');
console.log(` node ~/.claude/tapd.mjs stories [workspace_id=${DEFAULT_WS}] [keyword]`);
console.log(' node ~/.claude/tapd.mjs story <story_id> [workspace_id]');
console.log(' node ~/.claude/tapd.mjs bugs [workspace_id]');
}
2. 首次登录
node ~/.claude/tapd.mjs login your@linkingmed.com yourpassword
输出 Login successful 表示成功,cookie 自动保存到 ~/.claude/tapd-cookie.txt。
验证是否正常工作:
node ~/.claude/tapd.mjs projects
# 应输出你有权限的项目列表
cookie 过期时(通常几周后),重新执行
login命令即可,无需其他操作。
3. 可选:设置别名(推荐)
在 ~/.zshrc 或 ~/.bashrc 末尾添加一行:
echo "alias tapd='node ~/.claude/tapd.mjs'" >> ~/.zshrc
source ~/.zshrc
之后直接使用简短命令:
tapd projects
tapd stories
tapd story 1008229
常用命令
查看所有项目列表
node ~/.claude/tapd.mjs projects
输出示例:
67139335 LinkMed normal
21580481 AI公共平台(AiPlan) normal
38189866 科研项目管理 normal
查看需求列表
# LinkMed 项目最新需求(默认)
node ~/.claude/tapd.mjs stories
# 指定项目
node ~/.claude/tapd.mjs stories 67139335
# 关键词搜索
node ~/.claude/tapd.mjs stories 67139335 知识库
输出示例:
Total: 614 | Showing: 20
[1008229] 知识库-对解析失败的文件新增重试按钮和功能
status=developing owner=张倩如;尹帮会; iteration=0.6.26.0(当前迭代)
[1008263] 【工作台】对话记录的优化
status=planning owner=尹帮会; iteration=0.6.27.0
查看需求详情
# 用需求的 short_id(如 1008229)
node ~/.claude/tapd.mjs story 1008229
查看 Bug 列表
node ~/.claude/tapd.mjs bugs
# 指定项目
node ~/.claude/tapd.mjs bugs 67139335
常用项目 ID
| 项目名 | workspace_id |
|---|---|
| LinkMed | 67139335 |
| AI公共平台(AiPlan) | 21580481 |
| 科研项目管理 | 38189866 |
| 专病数据库 | 59607085 |
| RAIC.OIS信息管理系统 | 58951789 |
完整列表通过 node ~/.claude/tapd.mjs projects 获取。
技术实现说明
TAPD 没有开放的公共 API 文档,本脚本通过以下方式逆向实现:
登录流程
TAPD 登录页面在提交前会用 AES-256-CBC + ZeroPadding 加密密码,加密用的 key 和 iv 连同密文一起发送给服务器(服务器用收到的 key/iv 解密)。脚本用 Node.js 内置 crypto 模块复现了这个加密逻辑,因此无需 CAPTCHA 即可完成登录。
登录接口:POST https://www.tapd.cn/cloud_logins/login
关键请求字段:
data[Login][email] 用户邮箱
data[Login][password] AES 加密后的密码(base64)
data[Login][encrypt_key] AES key(base64)
data[Login][encrypt_iv] AES IV(base64)
data[Login][via] encrypt_password
data[Login][type] 2
主要 API 接口
所有接口均需在请求头中携带登录 cookie,并设置 X-Requested-With: XMLHttpRequest。
| 接口 | 方法 | 说明 |
|---|---|---|
/api/workspace/workspaces/get_all_my_projects |
GET | 获取我的项目列表 |
/api/entity/stories/story_list_by_condition |
POST | 查询需求列表 |
/api/entity/program_entity_preview/story_preview_data |
GET | 获取需求完整详情(含描述、验收标准) |
/api/entity/bugs/bug_list_by_condition |
POST | 查询 Bug 列表 |
需求列表请求体示例:
{
"workspace_ids": ["67139335"],
"page": 1,
"page_count": 20,
"search_data": { "keyword": "关键词" }
}
这些接口是通过分析 TAPD 前端 JS bundle(
static-fe.tapd.cn/chunk-common.*.js)发现的,不在官方文档中,未来版本可能变更。
需求详情获取的关键经验
内部 ID 构造规则
story 命令不依赖关键词搜索,而是直接构造内部 ID:
内部 ID = '11' + workspaceId + shortId.padStart(9, '0')
示例(workspace=67139335,short_id=1008260):
'11' + '67139335' + '001008260' = '1167139335001008260'
为什么不用关键词搜索: TAPD 的关键词搜索是全文匹配,需求 ID(如 1008260)通常不出现在标题里,导致搜索返回 0 条结果,报 "not found"。直接构造 ID 可绕过此问题。
描述字段注意事项
- 描述存储为 HTML,需过滤标签才能读取纯文本
-
<img>标签不能丢弃——需求描述中的截图往往包含关键 UI 信息,应保留为[图片: URL] - 旧版脚本有
slice(0, 500)截断描述,已修复为完整输出
图片内容读取
需求描述中的图片(UI 示意图、交互截图)需要下载后用 Claude 视觉能力读取,才能获取完整的 UI 信息。
下载方式: 需携带登录 cookie + Referer 头,否则 TAPD 图片服务器只返回 1 byte:
const r = await fetch(imgUrl, {
headers: { cookie, 'User-Agent': UA, Referer: 'https://www.tapd.cn/' }
});
在工作流中的用法:
# 1. 获取需求详情(描述中含图片 URL)
node ~/.claude/tapd.mjs story 1008260
# 2. 下载图片到本地
# (从描述中提取 [图片: URL],用 cookie 下载,保存为 /tmp/tapd_xxx.png)
# 3. 用 Read 工具读取图片(Claude 视觉能力自动解析内容)
典型图片内容示例(1008257、1008258):
| 需求 | 图片描述 |
|---|---|
| 1008257 图1 | PDF 浏览器中选中文字弹出菜单(含"Quote in Chat"选项) |
| 1008257 图2 | 对话框底部出现 PDF 引用标签,悬停可预览文本 |
| 1008258 图1 | PDF 工具栏截图按钮高亮,页面进入截图模式 |
| 1008258 图2 | 截图完成后,对话框输入区出现截图缩略图 |
| 1008260 图1 | 工作台界面,箭头指向对话框操作栏,标注"增加一个按钮,'添加到文章中'" |