larksync 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ # Python
2
+ .venv/
3
+ .venv-gtk/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ .pytest_cache/
10
+
11
+ # 前端(gui/frontend/.gitignore 另外忽略 node_modules/ 和 dist/)
12
+ gui/frontend/node_modules/
13
+ gui/frontend/dist/
14
+
15
+ # 凭证:larksync 默认存在用户配置目录,这里防止误放进仓库
16
+ token.json
17
+ .feishu_token.json
18
+
19
+ # 测试时拉取到项目根目录的表格
20
+ /*.csv
21
+ /*.xlsx
22
+
23
+ # 编辑器
24
+ *~
25
+ \#*\#
26
+ .#*
27
+ .idea/
28
+ .vscode/
29
+ .DS_Store
larksync-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 libcyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: larksync
3
+ Version: 0.1.0
4
+ Summary: 飞书电子表格 / 多维表格的拉取与按主键更新(Feishu / Lark sheets & bitable sync)
5
+ Project-URL: Homepage, https://github.com/libcyz/larksync
6
+ Project-URL: Documentation, https://github.com/libcyz/larksync/blob/main/docs/python.md
7
+ Project-URL: Issues, https://github.com/libcyz/larksync/issues
8
+ Author-email: libcyz <2840424020@qq.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: bitable,csv,feishu,lark,spreadsheet,sync,xlsx
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: Chinese (Simplified)
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Office/Business
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: openpyxl>=3.1.5
25
+ Requires-Dist: platformdirs>=4
26
+ Provides-Extra: gui
27
+ Requires-Dist: pywebview>=6.2.1; extra == 'gui'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # larksync
31
+
32
+ 这是一个用来同步飞书表格的项目,大多数项目的非技术人员在使用飞书在线表格进行管理的时候经常会出现大量数据需要人工核对,可能又需要去重,于是添加新的表变的非常麻烦,而整理好的csv中如果出现重复的数据,直接复制的话就gameover,当然会有用豆包更新表格的声音,如果你用豆包更新表格,那我不要和你说话。
33
+
34
+ 对于技术人员来讲,在pipeline搭建好之后发现跑脚本前需要拉取在线表格更新后的信息,需要折返到对应表格,下载,另存为csv,大概率还会出现XXXX表(1),XXXX表(2),然后就需要在修改脚本路径和重命名表格这两个愚蠢选项中选择一项,诸如此类,larksync应运而生。
35
+
36
+ ## 更新规则
37
+
38
+ GUI 和 Python 代码使用同一套规则。
39
+
40
+ - 补空的格子默认写入。
41
+ - 不一致的格子默认不写入。人工确认后,才可以写入。
42
+ - 新增的行由调用方决定是否写入。GUI 默认不勾选新增的行。
43
+ - 本地文件中为空的格子不参与比较。larksync 不清空在线表的值。
44
+ - larksync 不删除在线表的行。撤销时,只删除本次写入新增的行。
45
+ - 写入前,larksync 重新读取涉及的行。预览后有人修改过这些格子时,写入整体中止。
46
+
47
+ ## 前提
48
+
49
+ 1. 创建一个飞书自建应用。
50
+ 2. 为应用开通以下权限:
51
+ ```json
52
+ {
53
+ "scopes": {
54
+ "tenant": [
55
+ "bitable:app",
56
+ "sheets:spreadsheet",
57
+ "wiki:wiki:readonly"
58
+ ],
59
+ "user": [
60
+ "bitable:app",
61
+ "sheets:spreadsheet",
62
+ "wiki:wiki:readonly"
63
+ ]
64
+ }
65
+ }
66
+ ```
67
+ 3. 在应用的重定向 URL 中添加 `http://localhost:8888/callback`。
68
+ 4. 发布应用版本。
69
+
70
+ 说明:权限修改并发布后,生效可以有几分钟的延迟;每个人必须使用自己的飞书应用,不同的人对在线表的权限不同。
71
+
72
+ ## GUI 使用
73
+
74
+ ### 首次配置
75
+
76
+ 1. 启动 larksync。结果:弹出「飞书应用与登录」窗口。
77
+ 2. 填写「App ID」和「App Secret」。
78
+ 3. 单击「保存并登录」。结果:浏览器打开飞书授权页。
79
+ 4. 在 180 秒内单击「授权」。结果:窗口右上角显示你的飞书用户名。
80
+
81
+ 登录状态保存在本机。下次启动时,不需要再登录。
82
+
83
+ ## 术语
84
+
85
+ - **主键**:用来匹配行的字段。本地文件的行和在线表的行,主键值相同时视为同一行。
86
+ - **联合主键**:多个字段一起作为主键。一个字段的值有重复时,使用联合主键。
87
+ - **补空**:在线表的格子为空,本地文件有值。
88
+ - **未匹配**:在线表有这一行,本地文件没有这一行。
89
+
90
+ ### 用本地文件更新在线表
91
+
92
+ 1. 在左侧列表中选择一张在线表。结果:顶部显示表头的前 8 个字段。
93
+ 2. 把本地文件拖入虚线框。也可以单击虚线框,选择本地文件。
94
+ 3. 在「列对应」中检查每一个 CSV 列对应的字段。同名的列自动对应。
95
+ 4. 勾选「主键」。一个字段的值有重复时,勾选多个字段。
96
+ 5. 勾选「更新」中要写入的列。表头的「更新」复选框可以全选。
97
+ 6. 单击「生成预览」。结果:显示「补空」「不一致」「新增」「未匹配」4 个页签。
98
+ 7. 在「不一致」中核对每一个格子。只勾选确认要覆盖的格子。
99
+ 8. 在「新增」中勾选确认要增加的行。
100
+ 9. 单击「写入飞书」。结果:弹出确认框。
101
+ 10. 单击「写入」。结果:显示「已写入飞书」。
102
+
103
+ 列对应和主键保存在表格配置中。下次拖入同样格式的本地文件时,自动恢复。
104
+
105
+
106
+ ### 其他操作
107
+
108
+ - 单击「刷新」:重新读取表头。在线表增加字段后,必须刷新。
109
+ - 单击「移除」:删除本机的表格配置。飞书上的在线表和写入记录不变。
110
+ - 单击右上角的用户名:修改 App ID 或 App Secret,或退出登录。
111
+
112
+ 开发者调用详见 [docs/python.md](https://github.com/libcyz/larksync/blob/main/docs/python.md)。
@@ -0,0 +1,83 @@
1
+ # larksync
2
+
3
+ 这是一个用来同步飞书表格的项目,大多数项目的非技术人员在使用飞书在线表格进行管理的时候经常会出现大量数据需要人工核对,可能又需要去重,于是添加新的表变的非常麻烦,而整理好的csv中如果出现重复的数据,直接复制的话就gameover,当然会有用豆包更新表格的声音,如果你用豆包更新表格,那我不要和你说话。
4
+
5
+ 对于技术人员来讲,在pipeline搭建好之后发现跑脚本前需要拉取在线表格更新后的信息,需要折返到对应表格,下载,另存为csv,大概率还会出现XXXX表(1),XXXX表(2),然后就需要在修改脚本路径和重命名表格这两个愚蠢选项中选择一项,诸如此类,larksync应运而生。
6
+
7
+ ## 更新规则
8
+
9
+ GUI 和 Python 代码使用同一套规则。
10
+
11
+ - 补空的格子默认写入。
12
+ - 不一致的格子默认不写入。人工确认后,才可以写入。
13
+ - 新增的行由调用方决定是否写入。GUI 默认不勾选新增的行。
14
+ - 本地文件中为空的格子不参与比较。larksync 不清空在线表的值。
15
+ - larksync 不删除在线表的行。撤销时,只删除本次写入新增的行。
16
+ - 写入前,larksync 重新读取涉及的行。预览后有人修改过这些格子时,写入整体中止。
17
+
18
+ ## 前提
19
+
20
+ 1. 创建一个飞书自建应用。
21
+ 2. 为应用开通以下权限:
22
+ ```json
23
+ {
24
+ "scopes": {
25
+ "tenant": [
26
+ "bitable:app",
27
+ "sheets:spreadsheet",
28
+ "wiki:wiki:readonly"
29
+ ],
30
+ "user": [
31
+ "bitable:app",
32
+ "sheets:spreadsheet",
33
+ "wiki:wiki:readonly"
34
+ ]
35
+ }
36
+ }
37
+ ```
38
+ 3. 在应用的重定向 URL 中添加 `http://localhost:8888/callback`。
39
+ 4. 发布应用版本。
40
+
41
+ 说明:权限修改并发布后,生效可以有几分钟的延迟;每个人必须使用自己的飞书应用,不同的人对在线表的权限不同。
42
+
43
+ ## GUI 使用
44
+
45
+ ### 首次配置
46
+
47
+ 1. 启动 larksync。结果:弹出「飞书应用与登录」窗口。
48
+ 2. 填写「App ID」和「App Secret」。
49
+ 3. 单击「保存并登录」。结果:浏览器打开飞书授权页。
50
+ 4. 在 180 秒内单击「授权」。结果:窗口右上角显示你的飞书用户名。
51
+
52
+ 登录状态保存在本机。下次启动时,不需要再登录。
53
+
54
+ ## 术语
55
+
56
+ - **主键**:用来匹配行的字段。本地文件的行和在线表的行,主键值相同时视为同一行。
57
+ - **联合主键**:多个字段一起作为主键。一个字段的值有重复时,使用联合主键。
58
+ - **补空**:在线表的格子为空,本地文件有值。
59
+ - **未匹配**:在线表有这一行,本地文件没有这一行。
60
+
61
+ ### 用本地文件更新在线表
62
+
63
+ 1. 在左侧列表中选择一张在线表。结果:顶部显示表头的前 8 个字段。
64
+ 2. 把本地文件拖入虚线框。也可以单击虚线框,选择本地文件。
65
+ 3. 在「列对应」中检查每一个 CSV 列对应的字段。同名的列自动对应。
66
+ 4. 勾选「主键」。一个字段的值有重复时,勾选多个字段。
67
+ 5. 勾选「更新」中要写入的列。表头的「更新」复选框可以全选。
68
+ 6. 单击「生成预览」。结果:显示「补空」「不一致」「新增」「未匹配」4 个页签。
69
+ 7. 在「不一致」中核对每一个格子。只勾选确认要覆盖的格子。
70
+ 8. 在「新增」中勾选确认要增加的行。
71
+ 9. 单击「写入飞书」。结果:弹出确认框。
72
+ 10. 单击「写入」。结果:显示「已写入飞书」。
73
+
74
+ 列对应和主键保存在表格配置中。下次拖入同样格式的本地文件时,自动恢复。
75
+
76
+
77
+ ### 其他操作
78
+
79
+ - 单击「刷新」:重新读取表头。在线表增加字段后,必须刷新。
80
+ - 单击「移除」:删除本机的表格配置。飞书上的在线表和写入记录不变。
81
+ - 单击右上角的用户名:修改 App ID 或 App Secret,或退出登录。
82
+
83
+ 开发者调用详见 [docs/python.md](https://github.com/libcyz/larksync/blob/main/docs/python.md)。
@@ -0,0 +1,108 @@
1
+ # Python 调用
2
+
3
+ 术语和更新规则见 [README](../README.md)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install larksync # 需要 Python 3.10+
9
+ ```
10
+
11
+ ## 快速开始
12
+
13
+ ```python
14
+ import larksync
15
+ from larksync import Table
16
+
17
+ larksync.configure("cli_xxx", "app_secret") # 每台机器运行一次
18
+
19
+ t = Table("https://xxx.feishu.cn/wiki/<token>?sheet=<id>")
20
+
21
+ rows = t.pull("out.csv") # 拉取:返回 list[dict],同时写入 CSV
22
+
23
+ plan = t.diff("new.csv", key="ID")
24
+ print(plan) # 补空 12 格 / 新增 3 行 / 不一致 2 格(未写入)/ 未匹配 5 行
25
+ record_id = plan.apply() # 写入
26
+
27
+ t.undo(record_id) # 撤销这次写入
28
+ ```
29
+
30
+ 首次运行时打开浏览器授权。之后 larksync 自动续期。
31
+
32
+ ## API
33
+
34
+ | 调用 | 说明 |
35
+ |---|---|
36
+ | `Table(url, auth="user", tab=None)` | 打开在线表。支持 `/wiki/`、`/sheets/`、`/base/` 链接 |
37
+ | `t.pull(path=None)` | 拉取全部行,返回 `list[dict]`。传 `path` 时同时写入 CSV |
38
+ | `t.columns()` | 表头 |
39
+ | `t.tabs()` | 文档中的全部在线表:`[(id, 标题)]` |
40
+ | `t.title` | 文档标题 |
41
+ | `t.diff(source, key, columns=None)` | 和本地数据比较,返回 `Plan` |
42
+ | `plan.apply()` | 写入,返回写入记录 id |
43
+ | `t.undo(record_id)` | 撤销一次写入 |
44
+ | `t.init(source)` | 用本地文件初始化空的在线表 |
45
+
46
+ - `tab`:指定在线表 id。不写时,用链接中的 `?sheet=` / `?table=`;链接也没有时,用第一张在线表。
47
+ - `source`:CSV 或 xlsx 的路径、文件内容 `bytes`,或 `list[dict]`。CSV 可以是 UTF-8 或 GBK。
48
+ - `key`:主键。联合主键写成列表:`key=["歌曲名", "歌手名"]`。
49
+ - `columns`:要更新的字段。不写时,更新本地数据中全部非主键列。
50
+
51
+ ### Plan
52
+
53
+ | 属性 | 内容 | `apply()` 时 |
54
+ |---|---|---|
55
+ | `plan.changed` | 补空的格子 | 写入 |
56
+ | `plan.conflicts` | 不一致的格子 | 不写入 |
57
+ | `plan.added` | 新增的行 | 写入 |
58
+ | `plan.unmatched` | 未匹配的行数 | — |
59
+
60
+ `apply()` 前可以修改 `plan`:
61
+
62
+ ```python
63
+ plan.changed += plan.conflicts # 确认后,覆盖不一致的格子
64
+ plan.added = [] # 不新增行
65
+ plan.apply()
66
+ ```
67
+
68
+ ## 在服务器上运行
69
+
70
+ 服务器没有浏览器。用 tenant 身份,凭证放在环境变量中:
71
+
72
+ ```bash
73
+ export LARKSYNC_APP_ID=cli_xxx
74
+ export LARKSYNC_APP_SECRET=xxx
75
+ ```
76
+
77
+ ```python
78
+ Table(url, auth="tenant").pull("out.csv")
79
+ ```
80
+
81
+ - tenant 身份必须把应用加为在线表的协作者。
82
+ - 要用 user 身份:在有浏览器的机器上登录一次,把配置目录中的 `config.json` 和 `token.json` 复制到服务器。
83
+
84
+ ## 示例:pipeline 中批量拉取
85
+
86
+ ```python
87
+ from larksync import LarkError, Table
88
+
89
+ URLS = {
90
+ "人员登记": "https://xxx.feishu.cn/wiki/<token>?sheet=<id>",
91
+ "组别登记": "https://xxx.feishu.cn/wiki/<token>?sheet=<id>",
92
+ }
93
+
94
+ for name, url in URLS.items():
95
+ try:
96
+ rows = Table(url, auth="tenant").pull(f"csv/{name}.csv")
97
+ print(f"{name}:{len(rows)} 行")
98
+ except LarkError as e: # larksync 只抛出这一种异常,错误信息包含处理方法
99
+ print(f"{name} 拉取失败:{e}")
100
+ ```
101
+
102
+ ## 配置目录
103
+
104
+ - Linux:`~/.config/larksync/`
105
+ - macOS:`~/Library/Application Support/larksync/`
106
+ - Windows:`%LOCALAPPDATA%\larksync\`
107
+
108
+ 目录中有 `config.json`(应用凭证)、`token.json`(登录状态)和 `history/`(写入记录)。`config.json` 和 `token.json` 不得提交到 git 仓库。
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "larksync"
3
+ version = "0.1.0"
4
+ description = "飞书电子表格 / 多维表格的拉取与按主键更新(Feishu / Lark sheets & bitable sync)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "libcyz", email = "2840424020@qq.com" }]
9
+ requires-python = ">=3.10"
10
+ keywords = ["feishu", "lark", "bitable", "spreadsheet", "csv", "xlsx", "sync"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Operating System :: OS Independent",
19
+ "Natural Language :: Chinese (Simplified)",
20
+ "Topic :: Office/Business",
21
+ "Intended Audience :: Developers",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27",
25
+ "openpyxl>=3.1.5",
26
+ "platformdirs>=4",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/libcyz/larksync"
31
+ Documentation = "https://github.com/libcyz/larksync/blob/main/docs/python.md"
32
+ Issues = "https://github.com/libcyz/larksync/issues"
33
+
34
+ [project.optional-dependencies]
35
+ gui = [
36
+ "pywebview>=6.2.1",
37
+ ]
38
+
39
+ [dependency-groups]
40
+ dev = ["pytest>=8"]
41
+
42
+ [build-system]
43
+ requires = ["hatchling>=1.27"]
44
+ build-backend = "hatchling.build"
45
+
46
+ # 源码包只放库本身、测试和文档;GUI 另外打安装包,不进 PyPI
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["src/larksync", "tests", "docs", "README.md", "LICENSE"]
@@ -0,0 +1,6 @@
1
+ """larksync:飞书电子表格 / 多维表格的拉取与按主键更新。"""
2
+ from .auth import configure
3
+ from .http import LarkError
4
+ from .table import Table
5
+
6
+ __all__ = ["Table", "LarkError", "configure"]
@@ -0,0 +1,179 @@
1
+ """本机飞书应用配置与登录。
2
+
3
+ 每人用自己的飞书应用(表格权限因人而异),configure() 一次即可。
4
+ user 身份(默认)首次登录打开浏览器,之后缓存 token、自动续期;tenant 身份无需登录。
5
+ """
6
+ import json
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import webbrowser
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+ from urllib.parse import parse_qs, urlencode, urlparse
15
+
16
+ import platformdirs
17
+
18
+ from .http import BASE, LarkError, call
19
+
20
+ CONFIG_DIR = Path(platformdirs.user_config_dir("larksync", appauthor=False))
21
+ CONFIG_FILE = CONFIG_DIR / "config.json"
22
+ TOKEN_FILE = CONFIG_DIR / "token.json"
23
+
24
+ SCOPES = "wiki:wiki:readonly sheets:spreadsheet bitable:app"
25
+ _EXPIRY_MARGIN_S = 300 # 提前 5 分钟判过期,免得请求发出的瞬间失效
26
+ _LOGIN_TIMEOUT_S = 180
27
+
28
+
29
+ def configure(app_id, app_secret, port=8888):
30
+ """保存本机的飞书应用。port 要和应用后台配置的重定向地址 http://localhost:<port>/callback 一致。"""
31
+ write_json(CONFIG_FILE, {"app_id": app_id.strip(), "app_secret": app_secret.strip(),
32
+ "port": int(port)})
33
+
34
+
35
+ def load_config():
36
+ cfg = read_json(CONFIG_FILE)
37
+ app_id = os.environ.get("LARKSYNC_APP_ID") or cfg.get("app_id")
38
+ app_secret = os.environ.get("LARKSYNC_APP_SECRET") or cfg.get("app_secret")
39
+ if not app_id or not app_secret:
40
+ raise LarkError("还没配置飞书应用:先运行 larksync.configure(app_id, app_secret),"
41
+ "或设置环境变量 LARKSYNC_APP_ID / LARKSYNC_APP_SECRET")
42
+ return app_id, app_secret, int(cfg.get("port") or 8888)
43
+
44
+
45
+ class Auth:
46
+ def __init__(self, method="user"):
47
+ if method not in ("user", "tenant"):
48
+ raise LarkError(f'auth 只能是 "user" 或 "tenant",收到 {method!r}')
49
+ self.method = method
50
+ self.app_id, self.app_secret, self.port = load_config()
51
+
52
+ def token(self):
53
+ c = self._cached()
54
+ if c and c["expires_at"] > time.time():
55
+ return c["access_token"]
56
+ if self.method == "tenant":
57
+ body = call("POST", "/auth/v3/tenant_access_token/internal",
58
+ json={"app_id": self.app_id, "app_secret": self.app_secret})
59
+ return self._save({"access_token": body["tenant_access_token"],
60
+ "expires_in": body.get("expire")})
61
+ if c and (t := self._refresh(c.get("refresh_token"))):
62
+ return t
63
+ return self._login()
64
+
65
+ def account(self):
66
+ """已登录的用户名(只看本地缓存,不联网);没登录返回 None。"""
67
+ c = self._cached()
68
+ if not c or (c["expires_at"] <= time.time() and not c.get("refresh_token")):
69
+ return None
70
+ return c.get("name") or "飞书用户"
71
+
72
+ def logout(self):
73
+ d = read_json(TOKEN_FILE)
74
+ if d.pop(self.method, None) is not None:
75
+ write_json(TOKEN_FILE, d)
76
+
77
+ # -- 缓存:按 app_id 隔离,换应用自动作废 --
78
+
79
+ def _cached(self):
80
+ d = read_json(TOKEN_FILE)
81
+ block = d.get(self.method) if d.get("app_id") == self.app_id else None
82
+ if isinstance(block, dict) and block.get("access_token"):
83
+ return block
84
+ return None
85
+
86
+ def _save(self, data):
87
+ token = data.get("access_token")
88
+ if not token:
89
+ raise LarkError(f"飞书没有返回 access_token:{data}")
90
+ d = read_json(TOKEN_FILE)
91
+ if d.get("app_id") != self.app_id:
92
+ d = {"app_id": self.app_id}
93
+ d[self.method] = {
94
+ "access_token": token,
95
+ "refresh_token": data.get("refresh_token", ""),
96
+ "expires_at": time.time() + float(data.get("expires_in") or 0) - _EXPIRY_MARGIN_S,
97
+ "name": data.get("name") or (d.get(self.method) or {}).get("name", ""),
98
+ }
99
+ try:
100
+ write_json(TOKEN_FILE, d)
101
+ except OSError:
102
+ pass # 缓存写不下去不该让整个流程失败
103
+ return token
104
+
105
+ # -- user 身份:续期 -> 浏览器登录 --
106
+
107
+ def _app_token(self):
108
+ body = call("POST", "/auth/v3/app_access_token/internal",
109
+ json={"app_id": self.app_id, "app_secret": self.app_secret})
110
+ return body["app_access_token"]
111
+
112
+ def _refresh(self, refresh_token):
113
+ if not refresh_token:
114
+ return None
115
+ try:
116
+ body = call("POST", "/authen/v1/refresh_access_token", token=self._app_token(),
117
+ json={"grant_type": "refresh_token", "refresh_token": refresh_token})
118
+ except LarkError:
119
+ # refresh_token 每次续期都会轮换:可能是别的进程刚续过,再看一眼缓存
120
+ c = self._cached()
121
+ if c and c["expires_at"] > time.time():
122
+ return c["access_token"]
123
+ return None
124
+ return self._save(body.get("data") or {})
125
+
126
+ def _login(self):
127
+ got = {}
128
+ done = threading.Event()
129
+
130
+ class Callback(BaseHTTPRequestHandler):
131
+ def do_GET(self):
132
+ got["code"] = (parse_qs(urlparse(self.path).query).get("code") or [None])[0]
133
+ self.send_response(200)
134
+ self.send_header("Content-Type", "text/html; charset=utf-8")
135
+ self.end_headers()
136
+ self.wfile.write("<h3>飞书授权完成,可以关闭这个页面了。</h3>".encode())
137
+ done.set()
138
+
139
+ def log_message(self, *a):
140
+ pass
141
+
142
+ try:
143
+ server = HTTPServer(("localhost", self.port), Callback)
144
+ except OSError as e:
145
+ raise LarkError(f"本机端口 {self.port} 被占用:换个端口重新 configure,"
146
+ f"并同步修改应用后台的重定向地址") from e
147
+ threading.Thread(target=server.serve_forever, daemon=True).start()
148
+ url = f"{BASE}/authen/v1/index?" + urlencode({
149
+ "app_id": self.app_id, "scope": SCOPES,
150
+ "redirect_uri": f"http://localhost:{self.port}/callback"})
151
+ print(f"请在浏览器中登录飞书并授权(没有自动打开就手动访问):\n {url}",
152
+ file=sys.stderr, flush=True)
153
+ webbrowser.open(url)
154
+ try:
155
+ if not done.wait(_LOGIN_TIMEOUT_S) or not got.get("code"):
156
+ raise LarkError(f"{_LOGIN_TIMEOUT_S} 秒内没有完成飞书授权。服务器上请改用 "
157
+ f'auth="tenant",或拷贝本机的 {TOKEN_FILE.name}')
158
+ finally:
159
+ server.shutdown()
160
+ server.server_close()
161
+ body = call("POST", "/authen/v1/access_token", token=self._app_token(),
162
+ json={"grant_type": "authorization_code", "code": got["code"]})
163
+ return self._save(body.get("data") or {})
164
+
165
+
166
+ def read_json(path):
167
+ try:
168
+ d = json.loads(path.read_text(encoding="utf-8"))
169
+ except (OSError, ValueError):
170
+ return {}
171
+ return d if isinstance(d, dict) else {}
172
+
173
+
174
+ def write_json(path, data):
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
177
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
178
+ os.chmod(tmp, 0o600)
179
+ os.replace(tmp, path) # 原子替换,并发写入不会留下半个文件