investdesk-quotes 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,10 +45,14 @@ A 股与场外公募基金行情查询 + 飞书多维表格持仓读写的 MCP
45
45
  两层,都不经过 MCP 参数:
46
46
 
47
47
  1. **客户端 → MCP**:无认证,绑定 `127.0.0.1`,仅供本机客户端使用
48
- 2. **MCP → 飞书**:复用部署机上已认证的 `lark-cli` profile,凭据存在 lark-cli 自己的凭据库里
48
+ 2. **MCP → 飞书**:飞书**自建应用**的 `tenant_access_token`,用 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 换取
49
49
 
50
50
  行情接口免认证。
51
51
 
52
+ > 0.3.0 起移除了 lark-cli 后端。此前迁移期同时支持「本机用户令牌」和「应用令牌」两套,
53
+ > 收敛成单后端是因为两条路会带来行为差异——OpenAPI 与 lark-cli 的记录形状完全不同,
54
+ > 曾导致字段全部解析为空。现在本机、云端、CI 走的是同一条路径。
55
+
52
56
  ## 配置
53
57
 
54
58
  工作目录默认 `~/.investdesk`,可用 `INVESTDESK_HOME` 覆盖。**代码在 Store 检出目录,状态在工作目录**,
@@ -63,7 +67,7 @@ INVESTDESK_PLATFORMS_TABLE=... # 平台表 table id
63
67
  MCP_PORT=8787 # 可选
64
68
  ```
65
69
 
66
- `.cache/` 与 `inbox/` 会自动创建,用于存放 lark-cli 的中间产物和邮件附件。
70
+ `.cache/` 与 `inbox/` 会自动创建,用于存放运行日志和邮件附件。
67
71
 
68
72
  ## 运行
69
73
 
@@ -84,10 +88,10 @@ npm run quote -- f:008284 # 手动查场外(f: 前缀)
84
88
  所以 `prepare` 会在发布前自动 `tsc`,`bin` 指向 `dist/`。运行时零依赖,构建期只需要 `typescript`。
85
89
  本地开发仍然可以直接 `node src/mcp/server.ts`(不在 `node_modules` 下,类型擦除可用)。
86
90
 
87
- ## 云端运行(脱离本机)
91
+ ## 认证配置(跑起来的前置条件)
88
92
 
89
- 本机跑依赖 `lark-cli` 的用户令牌——它躺在 macOS keychain 里,**云端没有 keychain,也就拿不到**。
90
- 所以要把定时任务搬到服务器 / CI,必须换成飞书**自建应用**:
93
+ 用飞书**自建应用**的 `tenant_access_token` 访问 Base,不依赖任何本机凭据,
94
+ 所以本机、服务器、CI 的配置完全一样:
91
95
 
92
96
  1. 在[飞书开发者后台](https://open.feishu.cn/app)创建企业自建应用,记下 `app_id` / `app_secret`
93
97
  2. 「权限管理」开通多维表格权限:`bitable:app`(包含记录读写)
@@ -99,19 +103,14 @@ FEISHU_APP_ID=cli_xxxxxxxxxxxx
99
103
  FEISHU_APP_SECRET=xxxxxxxxxxxxxxxx
100
104
  ```
101
105
 
102
- **代码不需要改。** 访问层按环境变量自动切换后端:
103
-
104
- | 环境变量 | 后端 | 能跑在哪 |
105
- |---|---|---|
106
- | 不设置 | `lark-cli`(用户令牌) | 仅本机 |
107
- | `FEISHU_APP_ID` + `FEISHU_APP_SECRET` | OpenAPI(`tenant_access_token`) | 云端 / CI / 容器 |
108
-
109
- 验证当前生效的后端:
106
+ 验证配置是否生效:
110
107
 
111
108
  ```bash
112
109
  node -e "import('./dist/feishu/base.js').then(async m=>{console.log('backend:',m.backendName());console.log('records:',(await m.listHoldings()).length)})"
113
110
  ```
114
111
 
112
+ 期望输出 `backend: openapi` 与 `records: <你的持仓数>`。
113
+
115
114
  > `tenant_access_token` 是应用身份,看到的数据范围由「应用被授权访问哪些资源」决定,
116
115
  > 与某个人的可见范围无关。换 Base 或换表时,协作者授权要同步更新。
117
116
 
@@ -122,6 +121,6 @@ URL 填 `http://127.0.0.1:8787/mcp` → 保存并打开开关。
122
121
 
123
122
  ## 限制
124
123
 
125
- - `status: limited`:依赖本机已认证的 lark-cli profile,不是装上即用
124
+ - `status: limited`:需要先创建飞书自建应用、开通 `bitable:app` 并加为 Base 协作者,不是装上即用
126
125
  - 只覆盖公开行情,**没有** Level-2、资金流、公告正文等付费数据
127
126
  - 不做投资建议;输出只有数据口径,不产出买卖结论
package/dist/config.js CHANGED
@@ -37,7 +37,6 @@ function loadDotEnv() {
37
37
  loadDotEnv();
38
38
  mkdirSync(WORK_DIR, { recursive: true });
39
39
  mkdirSync(CACHE_DIR, { recursive: true });
40
- export const LARK_CLI = process.env.LARK_CLI ?? 'lark-cli';
41
40
  /** 飞书多维表格坐标。换库时用环境变量覆盖。 */
42
41
  export const BASE_TOKEN = process.env.INVESTDESK_BASE_TOKEN ?? '';
43
42
  export const HOLDINGS_TABLE = process.env.INVESTDESK_HOLDINGS_TABLE ?? '';
@@ -47,19 +46,18 @@ export const MCP_PORT = Number(process.env.MCP_PORT ?? 8787);
47
46
  /**
48
47
  * 飞书自建应用凭据。
49
48
  *
50
- * 填了 → 走 OpenAPI(tenant_access_token),可在云端 / CI 跑;
51
- * 不填 → 走本机 lark-cli(用户令牌在 keychain 里)。
52
- *
53
- * 切换后端只改环境变量,代码不动。
49
+ * 0.3.0 起这是**唯一**的认证方式:用 tenant_access_token 调 OpenAPI,
50
+ * 不依赖任何本机凭据,本机与云端行为一致。
54
51
  */
55
52
  export const FEISHU_APP_ID = process.env.FEISHU_APP_ID ?? '';
56
53
  export const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET ?? '';
57
54
  export const FEISHU_BASE_URL = process.env.FEISHU_BASE_URL ?? 'https://open.feishu.cn';
58
- export function hasAppCredentials() {
59
- return Boolean(FEISHU_APP_ID && FEISHU_APP_SECRET);
60
- }
61
55
  export function assertFeishuConfigured() {
62
56
  const missing = [];
57
+ if (!FEISHU_APP_ID)
58
+ missing.push('FEISHU_APP_ID');
59
+ if (!FEISHU_APP_SECRET)
60
+ missing.push('FEISHU_APP_SECRET');
63
61
  if (!BASE_TOKEN)
64
62
  missing.push('INVESTDESK_BASE_TOKEN');
65
63
  if (!HOLDINGS_TABLE)
@@ -1,10 +1,8 @@
1
1
  /**
2
- * 飞书后端接口。
2
+ * 飞书后端的记录形状定义。
3
3
  *
4
- * 两种实现,按环境变量自动选择:
5
- * - larkCliBackend :复用本机已登录的 lark-cli,零配置,但只能在本机跑
6
- * - openapiBackend :飞书自建应用 + tenant_access_token,可在云端/CI 跑
7
- *
8
- * 上层(base.ts)只依赖这个接口,不关心背后是谁。
4
+ * 0.3.0 起只有一种实现(OpenAPI + tenant_access_token)。
5
+ * 保留这个接口是为了约束「后端返回的记录长什么样」,
6
+ * 上层(base.ts)直接消费 RawRecord。
9
7
  */
10
8
  export {};
@@ -1,36 +1,34 @@
1
1
  /**
2
- * 飞书多维表格访问层 —— 对上层暴露稳定的记录接口,底层可切换后端。
2
+ * 飞书多维表格访问层。
3
3
  *
4
- * 后端选择只看环境变量:
5
- * 有 FEISHU_APP_ID + FEISHU_APP_SECRET → OpenAPI(云端 / CI 可用)
6
- * 没有 → 本机 lark-cli(用户令牌在 keychain)
4
+ * **只使用飞书 OpenAPI**(自建应用 tenant_access_token):
5
+ * 不依赖任何本机凭据,所以本机、云端、CI 走的是同一条路径,
6
+ * 不存在「两套后端行为不一致」的问题。
7
7
  *
8
- * 上层代码(quotes / sync / mcp)完全不需要知道用的是哪种。
8
+ * 历史:0.2.x 曾保留 lark-cli(本机用户令牌)作为迁移期后备,
9
+ * 0.3.0 起移除。
9
10
  */
10
- import { BASE_TOKEN, FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_BASE_URL, HOLDINGS_TABLE, PLATFORMS_TABLE, assertFeishuConfigured, hasAppCredentials, } from '../config.js';
11
- import { LarkCliBackend } from './larkCli.js';
11
+ import { BASE_TOKEN, FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_BASE_URL, HOLDINGS_TABLE, PLATFORMS_TABLE, assertFeishuConfigured, } from '../config.js';
12
12
  import { OpenApiBackend } from './openapi.js';
13
13
  let cachedBackend = null;
14
- /** 当前生效的后端(按环境变量选择,结果缓存) */
14
+ /** 当前后端(只有一种,结果缓存) */
15
15
  export function getBackend() {
16
16
  if (cachedBackend)
17
17
  return cachedBackend;
18
18
  assertFeishuConfigured();
19
- cachedBackend = hasAppCredentials()
20
- ? new OpenApiBackend({
21
- appId: FEISHU_APP_ID,
22
- appSecret: FEISHU_APP_SECRET,
23
- appToken: BASE_TOKEN,
24
- baseUrl: FEISHU_BASE_URL,
25
- })
26
- : new LarkCliBackend();
19
+ cachedBackend = new OpenApiBackend({
20
+ appId: FEISHU_APP_ID,
21
+ appSecret: FEISHU_APP_SECRET,
22
+ appToken: BASE_TOKEN,
23
+ baseUrl: FEISHU_BASE_URL,
24
+ });
27
25
  return cachedBackend;
28
26
  }
29
- /** 后端名,用于日志 */
27
+ /** 后端名,用于日志(固定值,保留是为了让日志继续可读) */
30
28
  export function backendName() {
31
- return hasAppCredentials() ? 'openapi' : 'lark-cli';
29
+ return 'openapi';
32
30
  }
33
- /** 单选字段:lark-cli 返回数组,OpenAPI 返回字符串,两种都要兼容 */
31
+ /** 单选字段:OpenAPI 返回字符串,兼容数组形态 */
34
32
  function firstOf(value) {
35
33
  if (Array.isArray(value))
36
34
  return String(value[0] ?? '');
@@ -82,9 +80,20 @@ export async function listPlatformNames() {
82
80
  }
83
81
  return map;
84
82
  }
85
- /** 把价格写回「最新持仓」的「最新价格」字段(写入后「最新市值」公式自动重算) */
83
+ /**
84
+ * 把行情写回「最新持仓」:
85
+ * 最新价格 / 昨收价 / 日涨跌幅(%)
86
+ * 「最新市值」「日盈亏」是公式字段,写完后自动重算。
87
+ */
86
88
  export async function updateLatestPrices(updates) {
87
89
  if (updates.length === 0)
88
90
  return 0;
89
- return getBackend().batchUpdateRecords(HOLDINGS_TABLE, updates.map((update) => ({ recordId: update.recordId, fields: { 最新价格: update.price } })));
91
+ return getBackend().batchUpdateRecords(HOLDINGS_TABLE, updates.map((update) => {
92
+ const fields = { 最新价格: update.price };
93
+ if (update.previousPrice != null)
94
+ fields.昨收价 = update.previousPrice;
95
+ if (update.changePct != null)
96
+ fields['日涨跌幅(%)'] = update.changePct;
97
+ return { recordId: update.recordId, fields };
98
+ }));
90
99
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 飞书 OpenAPI 后端 —— 用自建应用的 tenant_access_token 直接调接口。
3
3
  *
4
- * 相比 lark-cli 后端,它不依赖本机 keychain,因此可以在云端 / CI / 定时函数里跑。
4
+ * 用自建应用的 tenant_access_token,不依赖本机 keychain,
5
+ * 因此本机 / 云端 / CI / 定时函数用的是同一条路径。
5
6
  * 代价是需要一次性配置:
6
7
  * 1. 在飞书开发者后台创建自建应用,拿到 app_id / app_secret
7
8
  * 2. 开通多维表格权限(bitable:app 或对应的记录读写 scope)
@@ -56,12 +57,12 @@ export class OpenApiBackend {
56
57
  return payload.data;
57
58
  }
58
59
  /**
59
- * OpenAPI 的记录形状和 lark-cli 的 ndjson 不一样:
60
+ * OpenAPI 返回的原始记录不能直接给上层用:
60
61
  * - 字段值包在 `fields` 里
61
62
  * - 单选返回字符串(不是数组)
62
63
  * - 数字返回字符串
63
64
  * - 关联字段是 `[{record_ids: [...], text}]`(不是 `[{id}]`)
64
- * 这里统一归一化成 lark-cli 的形状,让上层 base.ts 不用关心后端。
65
+ * 这里归一化成扁平、稳定的形状,让 base.ts 不用关心平台细节。
65
66
  */
66
67
  static normalize(record) {
67
68
  const rawFields = record.fields;
@@ -12,7 +12,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
12
12
  * 网络或接口异常则抛错,由调用方重试。
13
13
  */
14
14
  export async function fetchFundNav(code, timeoutMs = DEFAULT_TIMEOUT_MS) {
15
- const url = `${ENDPOINT}?fundCode=${encodeURIComponent(code)}&pageIndex=1&pageSize=1`;
15
+ const url = `${ENDPOINT}?fundCode=${encodeURIComponent(code)}&pageIndex=1&pageSize=2`;
16
16
  const controller = new AbortController();
17
17
  const timer = setTimeout(() => controller.abort(), timeoutMs);
18
18
  try {
@@ -26,16 +26,19 @@ export async function fetchFundNav(code, timeoutMs = DEFAULT_TIMEOUT_MS) {
26
26
  if (!res.ok)
27
27
  throw new Error(`东财净值 HTTP ${res.status}`);
28
28
  const payload = (await res.json());
29
- const row = payload.Data?.LSJZList?.[0];
29
+ const rows = payload.Data?.LSJZList ?? [];
30
+ const row = rows[0];
30
31
  if (!row?.DWJZ || !row.FSRQ)
31
32
  return null;
32
33
  const nav = Number(row.DWJZ);
33
34
  if (!Number.isFinite(nav) || nav <= 0)
34
35
  return null;
35
36
  const pct = Number(row.JZZZL);
37
+ const prevNav = Number(rows[1]?.DWJZ);
36
38
  return {
37
39
  code,
38
40
  nav,
41
+ previousNav: Number.isFinite(prevNav) && prevNav > 0 ? prevNav : null,
39
42
  date: row.FSRQ,
40
43
  changePct: Number.isFinite(pct) ? pct : null,
41
44
  };
@@ -87,6 +87,7 @@ export async function fetchQuotes(targets, options = {}) {
87
87
  name: target.name || q.name,
88
88
  price: q.price,
89
89
  changePct: q.changePct,
90
+ previousPrice: Number.isFinite(q.prevClose) && q.prevClose > 0 ? q.prevClose : null,
90
91
  tradeTime: q.tradeTime,
91
92
  source: 'tencent',
92
93
  });
@@ -125,6 +126,7 @@ export async function fetchQuotes(targets, options = {}) {
125
126
  name: target.name,
126
127
  price: nav.nav,
127
128
  changePct: nav.changePct,
129
+ previousPrice: nav.previousNav,
128
130
  tradeTime: nav.date,
129
131
  source: 'eastmoney-fund',
130
132
  });
package/dist/sync.js CHANGED
@@ -20,6 +20,8 @@ export async function syncPortfolioPrices(options = {}) {
20
20
  const updates = [...byRecord.entries()].map(([recordId, quote]) => ({
21
21
  recordId,
22
22
  price: quote.price,
23
+ previousPrice: quote.previousPrice,
24
+ changePct: quote.changePct,
23
25
  }));
24
26
  const written = await updateLatestPrices(updates);
25
27
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "investdesk-quotes",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "A 股与场外公募基金行情查询 + 飞书多维表格持仓读写的 MCP 服务。可装进豆包工作 / Claude Desktop 等支持 MCP 的 AI 宿主。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "directory": "external-capabilities/mcp/investdesk-quotes"
32
32
  },
33
33
  "scripts": {
34
- "build": "tsc -p tsconfig.build.json",
34
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
35
35
  "prepare": "npm run build",
36
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
37
37
  "mcp": "node dist/mcp/server.js",
@@ -43,4 +43,4 @@
43
43
  "@types/node": "^26.6.5",
44
44
  "typescript": "^7.0.2"
45
45
  }
46
- }
46
+ }
@@ -1,93 +0,0 @@
1
- /**
2
- * lark-cli 后端 —— 复用本机已登录的 lark-cli。
3
- *
4
- * 优点:零配置,凭证在 lark-cli 自己的凭据库里,项目里不存任何 secret。
5
- * 限制:只能在本机跑,云端没有 keychain 拿不到用户令牌。
6
- *
7
- * 注意:lark-cli 的 --output / @file 只接受相对路径,所以所有命令的 cwd
8
- * 固定为工作目录 WORK_DIR,产物也落在 WORK_DIR/.cache 下。
9
- */
10
- import { execFile } from 'node:child_process';
11
- import { promisify } from 'node:util';
12
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
13
- import path from 'node:path';
14
- import { BASE_TOKEN, CACHE_DIR, LARK_CLI, WORK_DIR, assertFeishuConfigured } from '../config.js';
15
- const execFileAsync = promisify(execFile);
16
- export class LarkCliBackend {
17
- name = 'lark-cli';
18
- async run(args) {
19
- assertFeishuConfigured();
20
- try {
21
- const { stdout } = await execFileAsync(LARK_CLI, args, {
22
- cwd: WORK_DIR,
23
- maxBuffer: 64 * 1024 * 1024,
24
- env: { ...process.env },
25
- });
26
- return stdout;
27
- }
28
- catch (error) {
29
- const err = error;
30
- const detail = (err.stderr || err.stdout || err.message || '').slice(0, 800);
31
- throw new Error(`lark-cli 执行失败:${detail}`);
32
- }
33
- }
34
- static parseEnvelope(stdout) {
35
- const envelope = JSON.parse(stdout);
36
- if (!envelope.ok) {
37
- throw new Error(`飞书接口返回失败:${envelope.error?.message ?? 'unknown'}`);
38
- }
39
- return envelope.data;
40
- }
41
- async listRecords(tableId, limit) {
42
- await mkdir(CACHE_DIR, { recursive: true });
43
- const relativeOut = `.cache/${tableId}.ndjson`;
44
- // --output 模式下 stdout 是 ndjson manifest(没有 ok 字段),
45
- // 真正的记录写在文件里;命令失败时 run 会抛错。
46
- await this.run([
47
- 'base', '+record-list',
48
- '--base-token', BASE_TOKEN,
49
- '--table-id', tableId,
50
- '--limit', String(limit),
51
- '--format', 'ndjson',
52
- '--output', relativeOut,
53
- '--overwrite',
54
- '--as', 'user',
55
- ]);
56
- const ndjson = await readFile(path.join(WORK_DIR, relativeOut), 'utf8');
57
- return ndjson
58
- .split('\n')
59
- .filter((line) => line.trim().length > 0)
60
- .map((line) => JSON.parse(line));
61
- }
62
- async batchUpdateRecords(tableId, updates) {
63
- if (updates.length === 0)
64
- return 0;
65
- await mkdir(CACHE_DIR, { recursive: true });
66
- const CHUNK = 200;
67
- let written = 0;
68
- for (let i = 0; i < updates.length; i += CHUNK) {
69
- const chunk = updates.slice(i, i + CHUNK);
70
- const patch = {};
71
- for (const update of chunk)
72
- patch[update.recordId] = update.fields;
73
- const fileName = `.cache/patch-${Date.now()}-${i}.json`;
74
- const absolute = path.join(WORK_DIR, fileName);
75
- await writeFile(absolute, JSON.stringify({ update_records: patch }), 'utf8');
76
- try {
77
- const stdout = await this.run([
78
- 'base', '+record-batch-update',
79
- '--base-token', BASE_TOKEN,
80
- '--table-id', tableId,
81
- '--json', `@./${fileName}`,
82
- '--as', 'user',
83
- ]);
84
- LarkCliBackend.parseEnvelope(stdout);
85
- written += chunk.length;
86
- }
87
- finally {
88
- await rm(absolute, { force: true });
89
- }
90
- }
91
- return written;
92
- }
93
- }