diff --git "a/docs/API\345\277\253\351\200\237\345\274\200\345\247\213\346\226\207\346\241\243.md" "b/docs/API\345\277\253\351\200\237\345\274\200\345\247\213\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..db449a0
--- /dev/null
+++ "b/docs/API\345\277\253\351\200\237\345\274\200\345\247\213\346\226\207\346\241\243.md"
@@ -0,0 +1,51 @@
+# API 快速开始文档
+
+## 一、前置条件
+- 已注册账号并获取 API Key;
+ 若未注册账号,请阅读:xxx
+ 若未获取 API Key,请阅读:xxx
+-
+
+
+## 二、API 请求示例
+
+### (1) cURL
+
+```curl
+
+
+```
+
+### (2) Python
+
+```python
+
+
+```
+
+### (3) JavaScript
+
+```javascript
+
+
+```
+
+
+## 三、
+
+
+## 四、错误码速查
+
+### 401:鉴权失败
+
+
+
+### 429:限流
+
+
+
+### 500:服务端错误
+
+
+
+其余错误排查方法详见:xxx
\ No newline at end of file
diff --git "a/docs/FAQ\344\270\216\345\270\270\350\247\201\346\212\245\351\224\231\346\216\222\346\237\245.md" "b/docs/FAQ\344\270\216\345\270\270\350\247\201\346\212\245\351\224\231\346\216\222\346\237\245.md"
new file mode 100644
index 0000000..3ecdcc3
--- /dev/null
+++ "b/docs/FAQ\344\270\216\345\270\270\350\247\201\346\212\245\351\224\231\346\216\222\346\237\245.md"
@@ -0,0 +1 @@
+# FAQ 与常见报错排查
\ No newline at end of file
diff --git "a/docs/HotAI\345\233\275\345\206\205API\345\271\263\345\217\260\347\224\250\346\210\267\346\211\213\345\206\214v1.md" "b/docs/HotAI\345\233\275\345\206\205API\345\271\263\345\217\260\347\224\250\346\210\267\346\211\213\345\206\214v1.md"
new file mode 100644
index 0000000..cbb3712
--- /dev/null
+++ "b/docs/HotAI\345\233\275\345\206\205API\345\271\263\345\217\260\347\224\250\346\210\267\346\211\213\345\206\214v1.md"
@@ -0,0 +1,50 @@
+# HotAI 国内 API 平台用户手册
+
+## 一、平台简介
+
+HotAI 是一款国内 AI 模型 API 聚合平台,可一站式接入 DeepSeek、xxx、xxx 等多种主流模型。
+
+
+
+
+
+## 二、快速入门
+
+### 1. 访问平台
+
+
+
+
+### 2. 账号注册与登录
+
+
+
+
+### 3. 控制台概览
+
+
+
+
+## 三、API Key 管理
+
+
+
+
+
+## 四、模型列表与选择
+
+
+
+
+
+## 五、用量与计费
+
+
+
+
+
+## 六、常见问题快速索引
+
+
+
+
diff --git "a/docs/\346\250\241\345\236\213\344\273\213\347\273\215\351\241\265\346\250\241\346\235\277.md" "b/docs/\346\250\241\345\236\213\344\273\213\347\273\215\351\241\265\346\250\241\346\235\277.md"
new file mode 100644
index 0000000..62d04b3
--- /dev/null
+++ "b/docs/\346\250\241\345\236\213\344\273\213\347\273\215\351\241\265\346\250\241\346\235\277.md"
@@ -0,0 +1,10 @@
+# 模型介绍
+
+本平台提供的模型及其属性如下:
+
+| 编号 | 模型ID | 提供商 | 适用场景 | 模型能力 | 鉴权类型 | API格式 | 价格 | 上下文长度 | 流式输出 | JSON输出 | 基础测试 | 响应速度 | key状态 | 并发限制 | 接入状态 | 链接 | 更新时间 | 备注 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| 1 | deepseek-v4-flash | 官方渠道 | 高并发客服对话、长文档 RAG 问答、大批量文本处理(摘要 / 分类 / 打标)、轻量工具调用、内容批量生成。 | 100 万 token 超长上下文支持,高并发低延迟,价格远低于 Pro 版,支持多模式推理(non-think/think-high/think-max)。 | API BASE | OPENAI | 缓存命中输入仅 0.02 元 / 百万 tokens,缓存未命中输入1 元 / 百万 tokens,输出 2 元 / 百万 tokens。 | 1M | 支持 | 支持 | 已完成 | 非流式总延迟:约 1372ms 流式首 token 延迟:约 1274ms | 可用 | 2500 | 已接入 | https://api-docs.deepseek.com/zh-cn | 2026/7/1 | 流式对话中存在乱码 |
+| 2 | deepseek-v4-pro | 官方渠道 | 适合复杂推理、高精度代码开发、专业文档分析、高风险合规业务,是对质量要求远高于成本 / 延迟的旗舰场景首选。 | 旗舰级通用模型,1.6T 总参 / 49B 激活,1M 超长上下文,强推理 + 高精度代码 + 复杂 Agent 能力,性能比肩国际顶级模型,适合高要求复杂任务。 | API BASE | OEPNAI | 缓存命中输入仅 0.025 元 / 百万 tokens,缓存未命中输入3 元 / 百万 tokens,输出 6 元 / 百万 tokens。 | 1M | 支持 | 支持 | 已完成 | 非流式总延迟:约 1822ms 流式首 token 延迟:约 1107ms | 可用 | 500 | 已接入 | https://api-docs.deepseek.com/zh-cn | 2026/7/1 | 基础对话和流式对话中存在输出空白的现象 |
+| 3 | kimi-k2.6 | 官方渠道 | Kimi K2.6 适合灵活短任务、多模态结合代码、高频轻量调用。 | Kimi K2.6 是 Kimi 最新最智能的模型,Kimi K2.6 的通用 Agent、代码、视觉理解等综合能力得到全面提升,其中在博士级难度的完整版人类最后的考试(Humanity’s Last Exam)、在考察模型真实软件工程能力的 SWE-Bench Pro、评估 Agent 深度检索能力的 DeepSearchQA 等基准测试中均取得行业领先的成绩,同时支持文本、图片与视频输入,思考与非思考模式,对话与 Agent 任务。 | API BASE | OEPNAI | 缓存命中输入仅 1.1 元 / 百万 tokens,缓存未命中输入6.5 元 / 百万 tokens,输出 27 元 / 百万 tokens。 | 256K | 支持 | 支持 | 已完成 | 待测试 | 可用 | 3 | 已接入 | https://platform.kimi.com/docs/overview | 2026/7/2 | 模型可用性有待进一步测试。 |
+| 4 | kimi-k2.7-code | 官方渠道 | K2.7 Code 主打复杂编码、长工程、Agent 自动化等专业代码场景。 | Kimi K2.7 Code 是 Kimi 迄今最智能的 Coding 模型,在长上下文中更可靠地遵循指令,能以更高的成功率完成编程任务,同时支持文本、图片与视频输入,思考模式,对话与 Agent 任务。 | API BASE | OEPNAI | 缓存命中输入仅 1.3 元 / 百万 tokens,缓存未命中输入6.5 元 / 百万 tokens,输出 28 元 / 百万 tokens。 | 256K | 支持 | 支持 | 已完成 | 待测试 | 可用 | 3 | 已接入 | https://platform.kimi.com/docs/overview | 2026/7/3 | 模型可用性有待进一步测试。 |
diff --git a/scripts/feishu/.env.example b/scripts/feishu/.env.example
new file mode 100644
index 0000000..905182e
--- /dev/null
+++ b/scripts/feishu/.env.example
@@ -0,0 +1,16 @@
+FEISHU_APP_ID=cli_xxx
+FEISHU_APP_SECRET=xxx
+
+FEISHU_OPEN_BROWSER=true
+FEISHU_REDIRECT_URI=http://localhost:3001/
+FEISHU_OAUTH_STATE=hotai-test
+
+FEISHU_SAVE_USER_ACCESS_TOKEN=true
+FEISHU_USER_ACCESS_TOKEN=u-xxx
+
+FEISHU_WIKI_URL=https://pcn43kg7pnzs.feishu.cn/wiki/DG5cwq12EiuaQGk8UbtcaQKdnif
+FEISHU_FETCH_DOC_AFTER_AUTH=true
+
+FEISHU_DOC_OUTPUT=docs/feishu_content.txt
+FEISHU_SYNC_MODEL_TEMPLATE=true
+FEISHU_MODEL_TEMPLATE_PATH=docs/模型介绍页模板.md
diff --git a/scripts/feishu/__pycache__/fetch_wiki_doc.cpython-311.pyc b/scripts/feishu/__pycache__/fetch_wiki_doc.cpython-311.pyc
new file mode 100644
index 0000000..af68979
Binary files /dev/null and b/scripts/feishu/__pycache__/fetch_wiki_doc.cpython-311.pyc differ
diff --git a/scripts/feishu/__pycache__/main.cpython-311.pyc b/scripts/feishu/__pycache__/main.cpython-311.pyc
new file mode 100644
index 0000000..666a909
Binary files /dev/null and b/scripts/feishu/__pycache__/main.cpython-311.pyc differ
diff --git a/scripts/feishu/__pycache__/oauth_server.cpython-311.pyc b/scripts/feishu/__pycache__/oauth_server.cpython-311.pyc
new file mode 100644
index 0000000..ed6342a
Binary files /dev/null and b/scripts/feishu/__pycache__/oauth_server.cpython-311.pyc differ
diff --git a/scripts/feishu/__pycache__/oauth_server.cpython-313.pyc b/scripts/feishu/__pycache__/oauth_server.cpython-313.pyc
new file mode 100644
index 0000000..642be8e
Binary files /dev/null and b/scripts/feishu/__pycache__/oauth_server.cpython-313.pyc differ
diff --git a/scripts/feishu/fetch_wiki_doc.py b/scripts/feishu/fetch_wiki_doc.py
new file mode 100644
index 0000000..7c0d85e
--- /dev/null
+++ b/scripts/feishu/fetch_wiki_doc.py
@@ -0,0 +1,211 @@
+# scripts/feishu/fetch_wiki_doc.py
+
+"""
+获取飞书知识库资源的文本内容。
+
+输入 URL 中 /wiki/ 后面的 token 是知识库节点 token。本脚本会先将它解析
+为实际云文档 token,再根据资源类型读取新版文档(docx)或电子表格(sheet)。
+"""
+
+from urllib.parse import urlparse
+
+import requests
+
+
+FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
+SUPPORTED_OBJ_TYPES = {"docx", "sheet"}
+
+
+class FeishuApiError(RuntimeError):
+ pass
+
+
+def build_feishu_error(response, payload=None):
+ """保留飞书返回体和 log_id,方便根据错误码定位权限或参数问题。"""
+ log_id = response.headers.get("x-tt-logid")
+ if payload is None:
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = response.text
+
+ detail = f"飞书 API 请求失败: HTTP {response.status_code}, response={payload}"
+ if log_id:
+ detail = f"{detail}, x-tt-logid={log_id}"
+ return FeishuApiError(detail)
+
+
+def extract_wiki_token(value):
+ """从 Wiki URL 中提取节点 token;如果传入本身就是 token,则原样返回。"""
+ if value.startswith("http://") or value.startswith("https://"):
+ path_parts = [part for part in urlparse(value).path.split("/") if part]
+ if len(path_parts) >= 2 and path_parts[0] == "wiki":
+ return path_parts[1]
+ raise ValueError(f"URL 不是有效的飞书知识库链接: {value}")
+ return value
+
+
+def request_json(method, url, *, user_access_token, **kwargs):
+ """统一封装飞书 OpenAPI 请求、鉴权头、JSON 解析和错误处理。"""
+ headers = kwargs.pop("headers", {})
+ headers["Authorization"] = f"Bearer {user_access_token}"
+
+ response = requests.request(method, url, headers=headers, timeout=20, **kwargs)
+ if not response.ok:
+ raise build_feishu_error(response)
+
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise FeishuApiError(f"飞书 API 返回非 JSON 响应: {response.text}") from exc
+
+ if payload.get("code") != 0:
+ raise build_feishu_error(response, payload)
+ return payload
+
+
+def get_wiki_node(user_access_token, wiki_token):
+ """查询 Wiki 节点,拿到背后的真实资源类型和 obj_token。"""
+ payload = request_json(
+ "GET",
+ f"{FEISHU_API_BASE}/wiki/v2/spaces/get_node",
+ user_access_token=user_access_token,
+ params={"token": wiki_token},
+ )
+ return payload["data"]["node"]
+
+
+def get_docx_raw_content(user_access_token, document_id, *, lang=0):
+ """读取新版飞书文档 docx 的纯文本内容。"""
+ payload = request_json(
+ "GET",
+ f"{FEISHU_API_BASE}/docx/v1/documents/{document_id}/raw_content",
+ user_access_token=user_access_token,
+ params={"lang": lang},
+ )
+ return payload["data"]["content"]
+
+
+def get_spreadsheet_sheets(user_access_token, spreadsheet_token):
+ """读取电子表格中的工作表列表。"""
+ payload = request_json(
+ "GET",
+ f"{FEISHU_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}/sheets/query",
+ user_access_token=user_access_token,
+ )
+ return payload["data"].get("sheets", [])
+
+
+def get_sheet_values(user_access_token, spreadsheet_token, sheet_id):
+ """读取单个工作表的全部单元格值。"""
+ payload = request_json(
+ "GET",
+ f"{FEISHU_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values/{sheet_id}",
+ user_access_token=user_access_token,
+ params={
+ "valueRenderOption": "ToString",
+ "dateTimeRenderOption": "FormattedString",
+ },
+ )
+ return payload["data"].get("valueRange", {}).get("values", [])
+
+
+def stringify_cell(value):
+ """将飞书单元格返回值规整成适合文本输出的字符串。"""
+ if value is None:
+ return ""
+ if isinstance(value, list):
+ return " ".join(stringify_cell(item) for item in value if item is not None)
+ if isinstance(value, dict):
+ text = value.get("text") or value.get("name") or value.get("value")
+ if text is not None:
+ return stringify_cell(text)
+ return str(value)
+
+
+def normalize_table(values):
+ """补齐不等长行,保证后续可以稳定转换为 Markdown 表格。"""
+ if not values:
+ return []
+
+ width = max(len(row) for row in values)
+ return [
+ [stringify_cell(row[index]) if index < len(row) else "" for index in range(width)]
+ for row in values
+ ]
+
+
+def escape_markdown_table_cell(value):
+ """转义 Markdown 表格单元格中的特殊字符。"""
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "
")
+
+
+def values_to_markdown_table(values):
+ """把二维单元格数组转换为 Markdown 表格文本。"""
+ rows = normalize_table(values)
+ if not rows:
+ return "_空工作表_"
+
+ header = [escape_markdown_table_cell(cell) for cell in rows[0]]
+ body = [
+ [escape_markdown_table_cell(cell) for cell in row]
+ for row in rows[1:]
+ ]
+ separator = ["---"] * len(header)
+
+ lines = [
+ "| " + " | ".join(header) + " |",
+ "| " + " | ".join(separator) + " |",
+ ]
+ lines.extend("| " + " | ".join(row) + " |" for row in body)
+ return "\n".join(lines)
+
+
+def get_sheet_content(user_access_token, spreadsheet_token):
+ """读取电子表格中所有可见的普通工作表,并合并为 Markdown 文本。"""
+ sheets = get_spreadsheet_sheets(user_access_token, spreadsheet_token)
+ if not sheets:
+ return "未读取到任何工作表。"
+
+ sections = []
+ for sheet in sheets:
+ if sheet.get("hidden"):
+ continue
+ if sheet.get("resource_type") != "sheet":
+ continue
+
+ title = sheet.get("title") or sheet["sheet_id"]
+ values = get_sheet_values(user_access_token, spreadsheet_token, sheet["sheet_id"])
+ sections.append(f"## {title}\n\n{values_to_markdown_table(values)}")
+
+ if not sections:
+ return "未读取到可导出的普通工作表。"
+ return "\n\n".join(sections)
+
+
+def fetch_wiki_doc_content(user_access_token, wiki_url_or_token, *, lang=0):
+ """根据 Wiki 节点背后的资源类型,分发到 docx 或 sheet 的读取逻辑。"""
+ wiki_token = extract_wiki_token(wiki_url_or_token)
+ node = get_wiki_node(user_access_token, wiki_token)
+
+ obj_type = node["obj_type"]
+ obj_token = node["obj_token"]
+ if obj_type == "docx":
+ return node, get_docx_raw_content(user_access_token, obj_token, lang=lang)
+ if obj_type == "sheet":
+ return node, get_sheet_content(user_access_token, obj_token)
+
+ if obj_type not in SUPPORTED_OBJ_TYPES:
+ raise NotImplementedError(
+ f"暂不支持的知识库对象类型: {obj_type!r}。"
+ f"当前脚本支持的类型: {', '.join(sorted(SUPPORTED_OBJ_TYPES))}。"
+ )
+
+
+if __name__ == "__main__":
+ from main import main
+ import sys
+
+ if len(sys.argv) == 1 or sys.argv[1] not in {"serve", "fetch"}:
+ sys.argv.insert(1, "fetch")
+ main()
diff --git a/scripts/feishu/main.py b/scripts/feishu/main.py
new file mode 100644
index 0000000..dc7b32e
--- /dev/null
+++ b/scripts/feishu/main.py
@@ -0,0 +1,309 @@
+# scripts/feishu/main.py
+
+"""
+飞书文档抓取工具统一入口。
+
+默认启动本地 OAuth 授权服务;也可以使用 fetch 子命令,直接用已有
+user_access_token 抓取知识库文档内容。
+"""
+
+import argparse
+import os
+import subprocess
+from threading import Timer
+from pathlib import Path
+import webbrowser
+
+from dotenv import load_dotenv
+
+from fetch_wiki_doc import fetch_wiki_doc_content
+from oauth_server import build_auth_url, create_app
+
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+REPO_ROOT = SCRIPT_DIR.parents[1]
+DEFAULT_WIKI_URL = "https://pcn43kg7pnzs.feishu.cn/wiki/DG5cwq12EiuaQGk8UbtcaQKdnif"
+DEFAULT_MODEL_TEMPLATE_PATH = "docs/模型介绍页模板.md"
+MODEL_TEMPLATE_HEADER = "# 模型介绍\n\n本平台提供的模型及其属性如下:"
+
+
+def load_local_env():
+ """加载与脚本同目录的 .env,统一管理本工具的运行配置。"""
+ load_dotenv(SCRIPT_DIR / ".env")
+
+
+def env_bool(name, default=False):
+ """按常见布尔字符串解析环境变量。"""
+ value = os.getenv(name)
+ if value is None:
+ return default
+ return value.lower() in {"1", "true", "yes", "on"}
+
+
+def is_wsl():
+ """检测当前进程是否运行在 WSL 中,用于选择合适的浏览器打开方式。"""
+ try:
+ os_release = Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8")
+ except OSError:
+ return False
+ return "microsoft" in os_release.lower() or "wsl" in os_release.lower()
+
+
+def open_browser(url):
+ """打开授权入口;WSL 下优先调用 Windows 默认浏览器。"""
+ if is_wsl():
+ try:
+ subprocess.Popen(
+ ["cmd.exe", "/c", "start", "", url],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return True
+ except OSError:
+ pass
+
+ return webbrowser.open(url)
+
+
+def require_env(name):
+ """读取必填环境变量,缺失时立即给出明确错误。"""
+ value = os.getenv(name)
+ if not value:
+ raise RuntimeError(f"缺少必要环境变量: {name}")
+ return value
+
+
+def write_content(output_path, content):
+ """写入抓取结果;相对路径统一按仓库根目录解析。"""
+ path = Path(output_path)
+ if not path.is_absolute():
+ path = REPO_ROOT / path
+
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ template_path = sync_model_template(content)
+ if template_path:
+ print(f"模型介绍页模板已更新: {template_path}")
+ return path
+
+
+def write_model_template(template_path, markdown_table):
+ """把 Markdown 表格写入模型介绍模板,同时固定保留模板前言。"""
+ path = Path(template_path)
+ if not path.is_absolute():
+ path = REPO_ROOT / path
+
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ f"{MODEL_TEMPLATE_HEADER}\n\n{markdown_table.strip()}\n",
+ encoding="utf-8",
+ )
+ return path
+
+
+def extract_first_markdown_table(content):
+ """从抓取结果中提取第一段 Markdown 表格,忽略工作表标题等说明文本。"""
+ table_lines = []
+ in_table = False
+ for line in content.splitlines():
+ if line.startswith("|"):
+ table_lines.append(line)
+ in_table = True
+ elif in_table:
+ break
+
+ return "\n".join(table_lines) if table_lines else content
+
+
+def sync_model_template(content):
+ """按配置把本次抓取结果同步写入模型介绍页模板。"""
+ if not env_bool("FEISHU_SYNC_MODEL_TEMPLATE", True):
+ return None
+
+ template_path = os.getenv("FEISHU_MODEL_TEMPLATE_PATH", DEFAULT_MODEL_TEMPLATE_PATH)
+ return write_model_template(template_path, extract_first_markdown_table(content))
+
+
+def upsert_env_value(path, key, value):
+ """新增或更新 .env 中的单个 key,保留其它配置行不变。"""
+ lines = []
+ found = False
+ if path.exists():
+ lines = path.read_text(encoding="utf-8").splitlines()
+
+ updated_lines = []
+ for line in lines:
+ if line.startswith(f"{key}="):
+ updated_lines.append(f"{key}={value}")
+ found = True
+ else:
+ updated_lines.append(line)
+
+ if not found:
+ updated_lines.append(f"{key}={value}")
+
+ path.write_text("\n".join(updated_lines) + "\n", encoding="utf-8")
+
+
+def save_user_access_token(access_token):
+ """把 OAuth 新获取的 user_access_token 写回 .env,便于后续 fetch 复用。"""
+ upsert_env_value(SCRIPT_DIR / ".env", "FEISHU_USER_ACCESS_TOKEN", access_token)
+ print("user_access_token 已写入 scripts/feishu/.env")
+
+
+def build_config(args):
+ """汇总命令行参数和环境变量,生成传给 OAuth 服务的配置对象。"""
+ return {
+ "app_id": require_env("FEISHU_APP_ID"),
+ "app_secret": require_env("FEISHU_APP_SECRET"),
+ "redirect_uri": require_env("FEISHU_REDIRECT_URI"),
+ "oauth_state": os.getenv("FEISHU_OAUTH_STATE", "hotai-test"),
+ "save_user_access_token": save_user_access_token,
+ "wiki_url": args.wiki or os.getenv("FEISHU_WIKI_URL", DEFAULT_WIKI_URL),
+ "fetch_doc_after_auth": env_bool("FEISHU_FETCH_DOC_AFTER_AUTH", True),
+ "write_content": write_content,
+ "doc_output": args.output or os.getenv("FEISHU_DOC_OUTPUT"),
+ "sync_model_template": sync_model_template,
+ "lang": args.lang
+ if env_bool("FEISHU_SAVE_USER_ACCESS_TOKEN", True)
+ else None,
+ }
+
+
+def run_server(args):
+ """启动本地 OAuth 服务,并按配置自动打开授权入口。"""
+ config = build_config(args)
+ app = create_app(config)
+
+ auth_url = build_auth_url(
+ config["app_id"],
+ config["redirect_uri"],
+ config["oauth_state"],
+ )
+ browser_host = "localhost" if args.host in {"0.0.0.0", "::"} else args.host
+ local_auth_url = f"http://{browser_host}:{args.port}/auth"
+
+ should_open_browser = args.open_browser
+ if should_open_browser is None:
+ should_open_browser = env_bool("FEISHU_OPEN_BROWSER", True)
+ if should_open_browser:
+ print(f"将自动打开浏览器访问: {local_auth_url}")
+ print(f"若自动打开失败,可手动打开本地入口: {local_auth_url}")
+ print(f"也可直接打开飞书授权链接: {auth_url}")
+ Timer(1.0, open_browser, args=(local_auth_url,)).start()
+ else:
+ print(f"请手动打开本地入口: {local_auth_url}")
+ print(f"或直接打开飞书授权链接: {auth_url}")
+
+ app.run(host=args.host, port=args.port, debug=args.debug)
+
+
+def run_fetch(args):
+ """跳过 OAuth,直接使用已有 user_access_token 抓取文档内容。"""
+ user_access_token = args.user_access_token or os.getenv("FEISHU_USER_ACCESS_TOKEN")
+ if not user_access_token:
+ raise RuntimeError(
+ "缺少 user_access_token。请在 scripts/feishu/.env 中设置 "
+ "FEISHU_USER_ACCESS_TOKEN,或通过 --user-access-token 传入。"
+ )
+
+ wiki_url = args.wiki or os.getenv("FEISHU_WIKI_URL", DEFAULT_WIKI_URL)
+ output = args.output or os.getenv("FEISHU_DOC_OUTPUT")
+ node, content = fetch_wiki_doc_content(user_access_token, wiki_url, lang=args.lang)
+
+ print(f"title: {node['title']}")
+ print(f"obj_type: {node['obj_type']}")
+ print(f"obj_token: {node['obj_token']}")
+
+ if output:
+ output_path = write_content(output, content)
+ print(f"内容已写入: {output_path}")
+ else:
+ template_path = sync_model_template(content)
+ if template_path:
+ print(f"模型介绍页模板已更新: {template_path}")
+ print()
+ print(content)
+
+
+def parse_args():
+ """解析 CLI 子命令;未指定子命令时默认进入 serve 模式。"""
+ parser = argparse.ArgumentParser(description="飞书 OAuth 授权与知识库文档抓取工具。")
+ subparsers = parser.add_subparsers(dest="command")
+
+ server_parser = subparsers.add_parser(
+ "serve",
+ help="启动本地 OAuth 授权服务,授权成功后可自动抓取文档。",
+ )
+ server_parser.add_argument(
+ "--wiki",
+ help="飞书知识库 URL 或 wiki token,默认读取 FEISHU_WIKI_URL。",
+ )
+ server_parser.add_argument(
+ "--output",
+ help="文档内容输出路径,默认读取 FEISHU_DOC_OUTPUT。",
+ )
+ server_parser.add_argument(
+ "--lang",
+ type=int,
+ default=0,
+ help="@用户 的显示语言:0 为默认名称,1 为英文名称。",
+ )
+ server_parser.add_argument("--host", default="localhost", help="本地服务监听地址。")
+ server_parser.add_argument("--port", type=int, default=3001, help="本地服务端口。")
+ server_parser.add_argument("--debug", action="store_true", help="启用 Flask debug 模式。")
+ server_parser.add_argument(
+ "--open-browser",
+ dest="open_browser",
+ action="store_true",
+ default=None,
+ help="启动服务后自动打开授权入口。",
+ )
+ server_parser.add_argument(
+ "--no-open-browser",
+ dest="open_browser",
+ action="store_false",
+ help="启动服务后不自动打开授权入口。",
+ )
+ server_parser.set_defaults(func=run_server)
+
+ fetch_parser = subparsers.add_parser(
+ "fetch",
+ help="直接使用已有 user_access_token 抓取文档。",
+ )
+ fetch_parser.add_argument(
+ "wiki",
+ nargs="?",
+ help="飞书知识库 URL 或 wiki token,默认读取 FEISHU_WIKI_URL。",
+ )
+ fetch_parser.add_argument(
+ "--user-access-token",
+ help="飞书 user_access_token,默认读取 FEISHU_USER_ACCESS_TOKEN。",
+ )
+ fetch_parser.add_argument(
+ "--output",
+ help="文档内容输出路径,默认读取 FEISHU_DOC_OUTPUT。",
+ )
+ fetch_parser.add_argument(
+ "--lang",
+ type=int,
+ default=0,
+ help="@用户 的显示语言:0 为默认名称,1 为英文名称。",
+ )
+ fetch_parser.set_defaults(func=run_fetch)
+
+ args = parser.parse_args()
+ if args.command is None:
+ args = parser.parse_args(["serve"])
+ return args
+
+
+def main():
+ """程序入口:先加载配置,再分发到 serve 或 fetch。"""
+ load_local_env()
+ args = parse_args()
+ args.func(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/feishu/oauth_server.py b/scripts/feishu/oauth_server.py
new file mode 100644
index 0000000..b39de80
--- /dev/null
+++ b/scripts/feishu/oauth_server.py
@@ -0,0 +1,154 @@
+# scripts/feishu/oauth_server.py
+
+"""
+本地 OAuth2 授权服务。
+
+该模块只负责创建 Flask 应用和处理飞书 OAuth 回调。
+环境变量加载、运行模式选择、文档抓取配置由统一入口 main.py 负责。
+"""
+
+from urllib.parse import urlencode
+
+from flask import Flask, redirect, request
+import requests
+
+from fetch_wiki_doc import fetch_wiki_doc_content
+
+
+TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
+AUTH_URL = "https://accounts.feishu.cn/open-apis/authen/v1/index"
+
+
+def build_auth_url(app_id, redirect_uri, state):
+ """构造飞书 OAuth 授权页 URL。"""
+ query = urlencode({
+ "app_id": app_id,
+ "redirect_uri": redirect_uri,
+ "state": state,
+ })
+ return f"{AUTH_URL}?{query}"
+
+
+def exchange_code_for_user_access_token(app_id, app_secret, code):
+ """用 OAuth 回调中的 code 换取 user_access_token。"""
+ payload = {
+ "app_id": app_id,
+ "app_secret": app_secret,
+ "code": code,
+ "grant_type": "authorization_code",
+ }
+
+ response = requests.post(TOKEN_URL, json=payload, timeout=20)
+ try:
+ result = response.json()
+ except ValueError as exc:
+ response.raise_for_status()
+ raise RuntimeError(f"飞书 Token 接口返回非 JSON 响应: {response.text}") from exc
+
+ if not response.ok:
+ raise RuntimeError(f"飞书 Token 接口请求失败: HTTP {response.status_code}, response={result}")
+ if result.get("code") != 0:
+ raise RuntimeError(f"获取 Token 失败: {result}")
+ return result["data"]
+
+
+def fetch_doc_after_auth(access_token, config):
+ """授权成功后立即使用新 token 抓取配置中的 Wiki 资源。"""
+ node, content = fetch_wiki_doc_content(
+ access_token,
+ config["wiki_url"],
+ lang=config["lang"],
+ )
+
+ print(f"文档标题: {node['title']}")
+ print(f"文档类型: {node['obj_type']}")
+ print(f"文档 token: {node['obj_token']}")
+
+ if config["doc_output"]:
+ output_path = config["write_content"](config["doc_output"], content)
+ print(f"文档内容已写入: {output_path}")
+ else:
+ template_path = config["sync_model_template"](content)
+ if template_path:
+ print(f"模型介绍页模板已更新: {template_path}")
+ print("文档内容:")
+ print(content)
+
+ return node, content
+
+
+def create_app(config):
+ """创建 Flask 应用;所有运行配置都由 main.py 注入。"""
+ app = Flask(__name__)
+
+ @app.route("/auth")
+ def auth():
+ return redirect(
+ build_auth_url(
+ config["app_id"],
+ config["redirect_uri"],
+ config["oauth_state"],
+ )
+ )
+
+ @app.route("/")
+ def callback():
+ # 飞书 OAuth 回调会携带 code 和 state,state 用于防止串改请求。
+ code = request.args.get("code")
+ state = request.args.get("state")
+
+ if not code:
+ return "授权失败,未获取到授权码", 400
+ if state != config["oauth_state"]:
+ return "授权失败,state 不匹配", 400
+
+ print(f"成功获取授权码: {code}")
+
+ # code 只能使用一次,拿到后立即换取 user_access_token。
+ try:
+ token_data = exchange_code_for_user_access_token(
+ config["app_id"],
+ config["app_secret"],
+ code,
+ )
+ except Exception as exc:
+ print(f"❌ 获取 Token 失败: {exc}")
+ return f"授权失败: {exc}", 400
+
+ access_token = token_data["access_token"]
+ print(f"✅ 获取 Token 成功: {access_token}")
+ if config["save_user_access_token"]:
+ # 保存 token 只是为了后续 fetch 复用;本次抓取会直接使用内存中的新 token。
+ config["save_user_access_token"](access_token)
+
+ if not config["fetch_doc_after_auth"]:
+ return "授权成功!user_access_token 已打印到控制台,已跳过文档抓取。"
+
+ try:
+ node, content = fetch_doc_after_auth(access_token, config)
+ except Exception as exc:
+ print(f"❌ 文档抓取失败: {exc}")
+ return f"授权成功,但文档抓取失败: {exc}", 500
+
+ output_msg = (
+ f"文档内容已写入 {config['doc_output']}"
+ if config["doc_output"]
+ else "文档内容已打印到控制台"
+ )
+ return (
+ f"授权成功,并已完成文档抓取!
"
+ f"标题:{node['title']}
"
+ f"内容长度:{len(content)} 字符
"
+ f"{output_msg}"
+ )
+
+ return app
+
+
+if __name__ == "__main__":
+ from main import main
+ import sys
+
+ if len(sys.argv) == 1:
+ sys.argv.append("serve")
+ main()
diff --git a/scripts/feishu/requirements.txt b/scripts/feishu/requirements.txt
new file mode 100644
index 0000000..e4b68c8
--- /dev/null
+++ b/scripts/feishu/requirements.txt
@@ -0,0 +1,3 @@
+python-dotenv
+flask
+requests