TAPD需求获取脚本指南.md 14.9 KB

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+(使用内置 fetchcrypto,无需安装任何依赖)
  • 脚本存放在 ~/.claude/tapd.mjs(用户级,跨项目复用)

安装配置

前置要求

确认 Node.js 版本 ≥ 18(脚本使用内置 fetchcrypto,无需安装任何 npm 包):

node -v   # 需要 v18.0.0 以上

如果版本过低,可通过 fnmnvm 升级:

# 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 工作台界面,箭头指向对话框操作栏,标注"增加一个按钮,'添加到文章中'"