558 lines
16 KiB
TypeScript
558 lines
16 KiB
TypeScript
const FEISHU_API_ORIGIN = "https://open.feishu.cn";
|
||
const MAX_SHEET_ROWS = 5_000;
|
||
const MAX_SHEET_COLUMNS = 100;
|
||
const MAX_CONTENT_ROWS = 1_000;
|
||
const MAX_MEDIA_BYTES = 20_000_000;
|
||
|
||
export type FeishuBindings = {
|
||
FEISHU_APP_ID?: string;
|
||
FEISHU_APP_SECRET?: string;
|
||
};
|
||
|
||
export type FeishuSourceImage = {
|
||
index: number;
|
||
fileToken: string;
|
||
width: number | null;
|
||
height: number | null;
|
||
};
|
||
|
||
export type FeishuSourceRow = {
|
||
sourceRow: number;
|
||
title: string;
|
||
body: string;
|
||
images: FeishuSourceImage[];
|
||
};
|
||
|
||
export type FeishuSource = {
|
||
url: string;
|
||
wikiToken: string;
|
||
spreadsheetToken: string;
|
||
sheetId: string;
|
||
sheetName: string;
|
||
syncedAt: string;
|
||
columns: string[];
|
||
rows: FeishuSourceRow[];
|
||
};
|
||
|
||
type FetchLike = typeof fetch;
|
||
|
||
type FeishuEnvelope<T> = {
|
||
code?: number;
|
||
msg?: string;
|
||
data?: T;
|
||
tenant_access_token?: string;
|
||
expire?: number;
|
||
};
|
||
|
||
type SheetInfo = {
|
||
sheet_id?: string;
|
||
title?: string;
|
||
hidden?: boolean;
|
||
resource_type?: string;
|
||
grid_properties?: {
|
||
row_count?: number;
|
||
column_count?: number;
|
||
};
|
||
};
|
||
|
||
type CachedAccessToken = {
|
||
appId: string;
|
||
token: string;
|
||
expiresAt: number;
|
||
};
|
||
|
||
let cachedAccessToken: CachedAccessToken | null = null;
|
||
|
||
export class FeishuSourceError extends Error {
|
||
status: number;
|
||
|
||
constructor(message: string, status = 500) {
|
||
super(message);
|
||
this.name = "FeishuSourceError";
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
function bindingValue(value: unknown) {
|
||
return String(value ?? "").trim();
|
||
}
|
||
|
||
function providerMessage(value: unknown) {
|
||
const message = String(value ?? "").trim();
|
||
return message
|
||
.replace(/https?:\/\/[^\s"'<>]+/gi, "[飞书权限链接]")
|
||
.slice(0, 240);
|
||
}
|
||
|
||
function normalizeHeader(value: unknown) {
|
||
return cellText(value)
|
||
.toLowerCase()
|
||
.replace(/[\s()()【】[\]_\-—::/\\]+/g, "");
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function cellText(value: unknown): string {
|
||
if (value === null || value === undefined) return "";
|
||
if (typeof value === "string") return value.trim();
|
||
if (typeof value === "number" || typeof value === "boolean") {
|
||
return String(value);
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value
|
||
.map(cellText)
|
||
.filter(Boolean)
|
||
.join("");
|
||
}
|
||
if (!isRecord(value) || value.type === "embed-image") return "";
|
||
if (typeof value.text === "string") return value.text.trim();
|
||
if (typeof value.value === "string") return value.value.trim();
|
||
return "";
|
||
}
|
||
|
||
function extractImages(value: unknown, output: FeishuSourceImage[]) {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) extractImages(item, output);
|
||
return;
|
||
}
|
||
if (!isRecord(value)) return;
|
||
const fileToken = bindingValue(value.fileToken ?? value.image_token);
|
||
if (value.type === "embed-image" && fileToken) {
|
||
output.push({
|
||
index: 0,
|
||
fileToken,
|
||
width:
|
||
typeof value.width === "number"
|
||
? value.width
|
||
: typeof value.image_width === "number"
|
||
? value.image_width
|
||
: null,
|
||
height:
|
||
typeof value.height === "number"
|
||
? value.height
|
||
: typeof value.image_height === "number"
|
||
? value.image_height
|
||
: null,
|
||
});
|
||
}
|
||
for (const child of Object.values(value)) {
|
||
if (child !== value.fileToken && child !== value.image_token) {
|
||
extractImages(child, output);
|
||
}
|
||
}
|
||
}
|
||
|
||
function columnName(index: number) {
|
||
let value = index + 1;
|
||
let result = "";
|
||
while (value > 0) {
|
||
const remainder = (value - 1) % 26;
|
||
result = String.fromCharCode(65 + remainder) + result;
|
||
value = Math.floor((value - 1) / 26);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function headerMatches(header: string, candidates: RegExp[]) {
|
||
return candidates.some((candidate) => candidate.test(header));
|
||
}
|
||
|
||
function findHeader(values: unknown[][]) {
|
||
let best:
|
||
| {
|
||
rowIndex: number;
|
||
idIndex: number;
|
||
titleIndex: number;
|
||
bodyIndex: number;
|
||
tagsIndex: number;
|
||
score: number;
|
||
}
|
||
| undefined;
|
||
|
||
for (let rowIndex = 0; rowIndex < Math.min(values.length, 20); rowIndex += 1) {
|
||
const headers = (values[rowIndex] ?? []).map(normalizeHeader);
|
||
const idIndex = headers.findIndex((header) =>
|
||
headerMatches(header, [/^id$/, /作品id/, /序号/, /编号/]),
|
||
);
|
||
const titleIndex = headers.findIndex((header) =>
|
||
headerMatches(header, [/标题/, /题目/]),
|
||
);
|
||
const bodyIndex = headers.findIndex((header) =>
|
||
headerMatches(header, [/笔记内容/, /正文/, /文案/, /^内容$/]),
|
||
);
|
||
const tagsIndex = headers.findIndex((header) =>
|
||
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
|
||
);
|
||
const score =
|
||
(titleIndex >= 0 ? 5 : 0) +
|
||
(bodyIndex >= 0 ? 5 : 0) +
|
||
(idIndex >= 0 ? 1 : 0) +
|
||
(tagsIndex >= 0 ? 1 : 0);
|
||
if (!best || score > best.score) {
|
||
best = {
|
||
rowIndex,
|
||
idIndex,
|
||
titleIndex,
|
||
bodyIndex,
|
||
tagsIndex,
|
||
score,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (!best || best.titleIndex < 0 || best.bodyIndex < 0) {
|
||
throw new FeishuSourceError(
|
||
"没有找到“标题”和“正文/笔记内容”列,请检查飞书表头",
|
||
422,
|
||
);
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function parseRows(values: unknown[][]) {
|
||
const header = findHeader(values);
|
||
const headerRow = values[header.rowIndex] ?? [];
|
||
const usedSourceRows = new Set<number>();
|
||
const rows: FeishuSourceRow[] = [];
|
||
let maxImageCount = 0;
|
||
|
||
for (
|
||
let rowIndex = header.rowIndex + 1;
|
||
rowIndex < values.length && rows.length < MAX_CONTENT_ROWS;
|
||
rowIndex += 1
|
||
) {
|
||
const row = values[rowIndex] ?? [];
|
||
const title = cellText(row[header.titleIndex]);
|
||
if (!title) continue;
|
||
const rawBody = cellText(row[header.bodyIndex]);
|
||
const tags =
|
||
header.tagsIndex >= 0 ? cellText(row[header.tagsIndex]) : "";
|
||
const body =
|
||
tags && !rawBody.includes(tags)
|
||
? [rawBody, tags].filter(Boolean).join("\n\n")
|
||
: rawBody;
|
||
const idValue =
|
||
header.idIndex >= 0
|
||
? Number.parseInt(cellText(row[header.idIndex]), 10)
|
||
: Number.NaN;
|
||
let sourceRow =
|
||
Number.isInteger(idValue) && idValue > 0 ? idValue : rowIndex + 1;
|
||
if (usedSourceRows.has(sourceRow)) sourceRow = rowIndex + 1;
|
||
usedSourceRows.add(sourceRow);
|
||
|
||
const collectedImages: FeishuSourceImage[] = [];
|
||
for (const cell of row) extractImages(cell, collectedImages);
|
||
const seenTokens = new Set<string>();
|
||
const images = collectedImages
|
||
.filter((image) => {
|
||
if (seenTokens.has(image.fileToken)) return false;
|
||
seenTokens.add(image.fileToken);
|
||
return true;
|
||
})
|
||
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
|
||
maxImageCount = Math.max(maxImageCount, images.length);
|
||
rows.push({ sourceRow, title, body, images });
|
||
}
|
||
|
||
if (rows.length === 0) {
|
||
throw new FeishuSourceError("表格中没有可导入的有效标题行", 422);
|
||
}
|
||
|
||
const matchedColumns = [
|
||
header.idIndex >= 0 ? cellText(headerRow[header.idIndex]) : "",
|
||
cellText(headerRow[header.titleIndex]) || "标题",
|
||
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
|
||
cellText(headerRow[header.bodyIndex]) || "正文",
|
||
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
columns: [...new Set(matchedColumns)],
|
||
rows,
|
||
};
|
||
}
|
||
|
||
async function jsonEnvelope<T>(
|
||
response: Response,
|
||
fallbackMessage: string,
|
||
): Promise<FeishuEnvelope<T>> {
|
||
const text = await response.text();
|
||
let payload: FeishuEnvelope<T>;
|
||
try {
|
||
payload = JSON.parse(text) as FeishuEnvelope<T>;
|
||
} catch {
|
||
throw new FeishuSourceError(
|
||
`${fallbackMessage}(飞书返回了异常响应)`,
|
||
502,
|
||
);
|
||
}
|
||
if (!response.ok || (typeof payload.code === "number" && payload.code !== 0)) {
|
||
const code = Number(payload.code);
|
||
const rawMessage = providerMessage(payload.msg);
|
||
if ([99991672, 99991679].includes(code)) {
|
||
throw new FeishuSourceError(
|
||
"飞书应用缺少电子表格或知识库读取权限,请先在飞书开放平台开通权限",
|
||
403,
|
||
);
|
||
}
|
||
if ([131006, 1310213].includes(code) || response.status === 403) {
|
||
throw new FeishuSourceError(
|
||
"飞书应用没有这张表的访问权限,请在表格中添加该文档应用或将应用加入知识库",
|
||
403,
|
||
);
|
||
}
|
||
if ([131005, 1310214].includes(code) || response.status === 404) {
|
||
throw new FeishuSourceError("没有找到对应的飞书表格", 404);
|
||
}
|
||
throw new FeishuSourceError(
|
||
rawMessage
|
||
? `${fallbackMessage}:${rawMessage}`
|
||
: `${fallbackMessage}(HTTP ${response.status})`,
|
||
response.status >= 400 ? response.status : 502,
|
||
);
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function feishuConfig(bindings: FeishuBindings) {
|
||
const appId = bindingValue(bindings.FEISHU_APP_ID);
|
||
const appSecret = bindingValue(bindings.FEISHU_APP_SECRET);
|
||
if (!appId || !appSecret) {
|
||
throw new FeishuSourceError(
|
||
"飞书 API 尚未配置,请先配置应用的 App ID 和 App Secret",
|
||
503,
|
||
);
|
||
}
|
||
return { appId, appSecret };
|
||
}
|
||
|
||
async function accessToken(
|
||
bindings: FeishuBindings,
|
||
fetchImpl: FetchLike,
|
||
) {
|
||
const { appId, appSecret } = feishuConfig(bindings);
|
||
if (
|
||
cachedAccessToken?.appId === appId &&
|
||
cachedAccessToken.expiresAt > Date.now() + 60_000
|
||
) {
|
||
return cachedAccessToken.token;
|
||
}
|
||
const response = await fetchImpl(
|
||
`${FEISHU_API_ORIGIN}/open-apis/auth/v3/tenant_access_token/internal`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||
signal: AbortSignal.timeout(12_000),
|
||
},
|
||
);
|
||
const payload = await jsonEnvelope<never>(response, "获取飞书访问凭证失败");
|
||
const token = bindingValue(payload.tenant_access_token);
|
||
if (!token) {
|
||
throw new FeishuSourceError("飞书没有返回有效访问凭证", 502);
|
||
}
|
||
cachedAccessToken = {
|
||
appId,
|
||
token,
|
||
expiresAt: Date.now() + Math.max(300, Number(payload.expire) || 7_200) * 1_000,
|
||
};
|
||
return token;
|
||
}
|
||
|
||
async function feishuGet<T>(
|
||
path: string,
|
||
token: string,
|
||
fetchImpl: FetchLike,
|
||
fallbackMessage: string,
|
||
params?: URLSearchParams,
|
||
) {
|
||
const url = new URL(path, FEISHU_API_ORIGIN);
|
||
if (params) url.search = params.toString();
|
||
const response = await fetchImpl(url.toString(), {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
},
|
||
signal: AbortSignal.timeout(15_000),
|
||
});
|
||
const payload = await jsonEnvelope<T>(response, fallbackMessage);
|
||
if (!payload.data) {
|
||
throw new FeishuSourceError(`${fallbackMessage}(响应缺少数据)`, 502);
|
||
}
|
||
return payload.data;
|
||
}
|
||
|
||
function parseSourceUrl(input: string) {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(input);
|
||
} catch {
|
||
throw new FeishuSourceError("飞书链接格式无效", 400);
|
||
}
|
||
const validHost =
|
||
url.hostname === "feishu.cn" ||
|
||
url.hostname.endsWith(".feishu.cn") ||
|
||
url.hostname === "larksuite.com" ||
|
||
url.hostname.endsWith(".larksuite.com");
|
||
if (!["http:", "https:"].includes(url.protocol) || !validHost) {
|
||
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
|
||
}
|
||
const match = url.pathname.match(/\/(wiki|sheets|spreadsheets)\/([^/?]+)/);
|
||
if (!match) {
|
||
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
|
||
}
|
||
return {
|
||
url,
|
||
kind: match[1],
|
||
token: match[2],
|
||
requestedSheetId: bindingValue(url.searchParams.get("sheet")),
|
||
};
|
||
}
|
||
|
||
export async function readFeishuSource(
|
||
input: string,
|
||
bindings: FeishuBindings,
|
||
fetchImpl: FetchLike = fetch,
|
||
): Promise<FeishuSource> {
|
||
const parsed = parseSourceUrl(input);
|
||
const token = await accessToken(bindings, fetchImpl);
|
||
let wikiToken = "";
|
||
let spreadsheetToken = parsed.token;
|
||
|
||
if (parsed.kind === "wiki") {
|
||
wikiToken = parsed.token;
|
||
const nodeData = await feishuGet<{
|
||
node?: { obj_type?: string; obj_token?: string };
|
||
}>(
|
||
"/open-apis/wiki/v2/spaces/get_node",
|
||
token,
|
||
fetchImpl,
|
||
"读取飞书知识库节点失败",
|
||
new URLSearchParams({ token: parsed.token }),
|
||
);
|
||
if (nodeData.node?.obj_type !== "sheet" || !nodeData.node.obj_token) {
|
||
throw new FeishuSourceError("该飞书 Wiki 链接不是电子表格", 422);
|
||
}
|
||
spreadsheetToken = nodeData.node.obj_token;
|
||
}
|
||
|
||
const sheetData = await feishuGet<{ sheets?: SheetInfo[] }>(
|
||
`/open-apis/sheets/v3/spreadsheets/${encodeURIComponent(spreadsheetToken)}/sheets/query`,
|
||
token,
|
||
fetchImpl,
|
||
"读取飞书工作表列表失败",
|
||
);
|
||
const visibleSheets = (sheetData.sheets ?? []).filter(
|
||
(sheet) =>
|
||
sheet.sheet_id &&
|
||
sheet.resource_type !== "bitable" &&
|
||
sheet.hidden !== true,
|
||
);
|
||
let selectedSheet = parsed.requestedSheetId
|
||
? visibleSheets.find((sheet) => sheet.sheet_id === parsed.requestedSheetId)
|
||
: undefined;
|
||
if (!parsed.requestedSheetId) {
|
||
if (visibleSheets.length === 1) {
|
||
selectedSheet = visibleSheets[0];
|
||
} else if (visibleSheets.length > 1) {
|
||
throw new FeishuSourceError(
|
||
"该表格包含多个工作表,请打开目标工作表后复制带 sheet 参数的链接",
|
||
422,
|
||
);
|
||
}
|
||
}
|
||
if (!selectedSheet?.sheet_id) {
|
||
throw new FeishuSourceError("链接中的工作表不存在或已隐藏", 404);
|
||
}
|
||
|
||
const rowCount = Math.max(
|
||
1,
|
||
Math.min(
|
||
MAX_SHEET_ROWS,
|
||
Number(selectedSheet.grid_properties?.row_count) || 200,
|
||
),
|
||
);
|
||
const columnCount = Math.max(
|
||
1,
|
||
Math.min(
|
||
MAX_SHEET_COLUMNS,
|
||
Number(selectedSheet.grid_properties?.column_count) || 26,
|
||
),
|
||
);
|
||
const range = `${selectedSheet.sheet_id}!A1:${columnName(columnCount - 1)}${rowCount}`;
|
||
const valuesParams = new URLSearchParams();
|
||
valuesParams.append("ranges", range);
|
||
const valuesData = await feishuGet<{
|
||
valueRanges?: Array<{ values?: unknown[][] }>;
|
||
}>(
|
||
`/open-apis/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/values_batch_get`,
|
||
token,
|
||
fetchImpl,
|
||
"读取飞书表格内容失败",
|
||
valuesParams,
|
||
);
|
||
const values = valuesData.valueRanges?.[0]?.values;
|
||
if (!Array.isArray(values)) {
|
||
throw new FeishuSourceError("飞书表格没有返回可读取的单元格", 422);
|
||
}
|
||
const parsedRows = parseRows(values);
|
||
|
||
return {
|
||
url: parsed.url.toString(),
|
||
wikiToken,
|
||
spreadsheetToken,
|
||
sheetId: selectedSheet.sheet_id,
|
||
sheetName: bindingValue(selectedSheet.title) || "未命名工作表",
|
||
syncedAt: new Date().toISOString(),
|
||
columns: parsedRows.columns,
|
||
rows: parsedRows.rows,
|
||
};
|
||
}
|
||
|
||
export async function downloadFeishuMedia(
|
||
fileToken: string,
|
||
bindings: FeishuBindings,
|
||
fetchImpl: FetchLike = fetch,
|
||
) {
|
||
const normalizedToken = bindingValue(fileToken);
|
||
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
|
||
throw new FeishuSourceError("飞书图片标识无效", 400);
|
||
}
|
||
const token = await accessToken(bindings, fetchImpl);
|
||
const response = await fetchImpl(
|
||
`${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`,
|
||
{
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
signal: AbortSignal.timeout(20_000),
|
||
},
|
||
);
|
||
const declaredSize = Number(response.headers.get("content-length"));
|
||
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
|
||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||
}
|
||
if (!response.ok) {
|
||
throw new FeishuSourceError(
|
||
response.status === 403
|
||
? "飞书应用没有这张图片的下载权限"
|
||
: `下载飞书图片失败(HTTP ${response.status})`,
|
||
response.status === 403 ? 403 : 502,
|
||
);
|
||
}
|
||
const bytes = await response.arrayBuffer();
|
||
if (bytes.byteLength > MAX_MEDIA_BYTES) {
|
||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||
}
|
||
return {
|
||
bytes,
|
||
contentType:
|
||
response.headers.get("content-type")?.split(";")[0] ||
|
||
"application/octet-stream",
|
||
};
|
||
}
|
||
|
||
export function clearFeishuAccessTokenCacheForTests() {
|
||
cachedAccessToken = null;
|
||
}
|