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