feat: 完善视频任务与 KOC 资源库
This commit is contained in:
@@ -2,7 +2,7 @@ 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;
|
||||
const DEFAULT_MAX_MEDIA_BYTES = 200_000_000;
|
||||
|
||||
export type FeishuBindings = {
|
||||
FEISHU_APP_ID?: string;
|
||||
@@ -16,11 +16,20 @@ export type FeishuSourceImage = {
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
export type FeishuSourceVideo = {
|
||||
index: number;
|
||||
fileToken: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number | null;
|
||||
};
|
||||
|
||||
export type FeishuSourceRow = {
|
||||
sourceRow: number;
|
||||
title: string;
|
||||
body: string;
|
||||
images: FeishuSourceImage[];
|
||||
videos: FeishuSourceVideo[];
|
||||
};
|
||||
|
||||
export type FeishuSource = {
|
||||
@@ -106,12 +115,47 @@ function cellText(value: unknown): string {
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
}
|
||||
if (!isRecord(value) || value.type === "embed-image") return "";
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.type === "embed-image" ||
|
||||
value.type === "attachment"
|
||||
) return "";
|
||||
if (typeof value.text === "string") return value.text.trim();
|
||||
if (typeof value.value === "string") return value.value.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractVideos(value: unknown, output: FeishuSourceVideo[]) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) extractVideos(item, output);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
const fileToken = bindingValue(value.fileToken ?? value.file_token);
|
||||
const mimeType = bindingValue(value.mimeType ?? value.mime_type);
|
||||
const name = bindingValue(value.text ?? value.name ?? value.file_name);
|
||||
const isVideo =
|
||||
value.type === "attachment" &&
|
||||
(mimeType.startsWith("video/") || /\.(?:mp4|mov|m4v|webm)$/i.test(name));
|
||||
if (isVideo && fileToken) {
|
||||
output.push({
|
||||
index: 0,
|
||||
fileToken,
|
||||
name: name || "视频",
|
||||
mimeType: mimeType || "video/mp4",
|
||||
size:
|
||||
typeof value.size === "number" && Number.isFinite(value.size)
|
||||
? value.size
|
||||
: null,
|
||||
});
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
if (child !== value.fileToken && child !== value.file_token) {
|
||||
extractVideos(child, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractImages(value: unknown, output: FeishuSourceImage[]) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) extractImages(item, output);
|
||||
@@ -167,6 +211,7 @@ function findHeader(values: unknown[][]) {
|
||||
titleIndex: number;
|
||||
bodyIndex: number;
|
||||
tagsIndex: number;
|
||||
videoIndex: number;
|
||||
score: number;
|
||||
}
|
||||
| undefined;
|
||||
@@ -185,9 +230,13 @@ function findHeader(values: unknown[][]) {
|
||||
const tagsIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
|
||||
);
|
||||
const videoIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/^视频\d*$/, /视频文件/, /视频素材/]),
|
||||
);
|
||||
const score =
|
||||
(titleIndex >= 0 ? 5 : 0) +
|
||||
(bodyIndex >= 0 ? 5 : 0) +
|
||||
(videoIndex >= 0 ? 2 : 0) +
|
||||
(idIndex >= 0 ? 1 : 0) +
|
||||
(tagsIndex >= 0 ? 1 : 0);
|
||||
if (!best || score > best.score) {
|
||||
@@ -197,6 +246,7 @@ function findHeader(values: unknown[][]) {
|
||||
titleIndex,
|
||||
bodyIndex,
|
||||
tagsIndex,
|
||||
videoIndex,
|
||||
score,
|
||||
};
|
||||
}
|
||||
@@ -217,6 +267,7 @@ function parseRows(values: unknown[][]) {
|
||||
const usedSourceRows = new Set<number>();
|
||||
const rows: FeishuSourceRow[] = [];
|
||||
let maxImageCount = 0;
|
||||
let maxVideoCount = 0;
|
||||
|
||||
for (
|
||||
let rowIndex = header.rowIndex + 1;
|
||||
@@ -253,7 +304,20 @@ function parseRows(values: unknown[][]) {
|
||||
})
|
||||
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
|
||||
maxImageCount = Math.max(maxImageCount, images.length);
|
||||
rows.push({ sourceRow, title, body, images });
|
||||
const collectedVideos: FeishuSourceVideo[] = [];
|
||||
if (header.videoIndex >= 0) {
|
||||
extractVideos(row[header.videoIndex], collectedVideos);
|
||||
}
|
||||
const seenVideoTokens = new Set<string>();
|
||||
const videos = collectedVideos
|
||||
.filter((video) => {
|
||||
if (seenVideoTokens.has(video.fileToken)) return false;
|
||||
seenVideoTokens.add(video.fileToken);
|
||||
return true;
|
||||
})
|
||||
.map((video, videoIndex) => ({ ...video, index: videoIndex + 1 }));
|
||||
maxVideoCount = Math.max(maxVideoCount, videos.length);
|
||||
rows.push({ sourceRow, title, body, images, videos });
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -266,6 +330,7 @@ function parseRows(values: unknown[][]) {
|
||||
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
|
||||
cellText(headerRow[header.bodyIndex]) || "正文",
|
||||
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
|
||||
...Array.from({ length: maxVideoCount }, (_, index) => `视频${index + 1}`),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
@@ -515,34 +580,37 @@ export async function downloadFeishuMedia(
|
||||
fileToken: string,
|
||||
bindings: FeishuBindings,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
options: { maxBytes?: number; label?: string; timeoutMs?: number } = {},
|
||||
) {
|
||||
const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES);
|
||||
const label = bindingValue(options.label) || "素材";
|
||||
const normalizedToken = bindingValue(fileToken);
|
||||
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
|
||||
throw new FeishuSourceError("飞书图片标识无效", 400);
|
||||
throw new FeishuSourceError(`飞书${label}标识无效`, 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),
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
|
||||
},
|
||||
);
|
||||
const declaredSize = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
|
||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||||
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
||||
throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new FeishuSourceError(
|
||||
response.status === 403
|
||||
? "飞书应用没有这张图片的下载权限"
|
||||
: `下载飞书图片失败(HTTP ${response.status})`,
|
||||
? `飞书应用没有这个${label}的下载权限`
|
||||
: `下载飞书${label}失败(HTTP ${response.status})`,
|
||||
response.status === 403 ? 403 : 502,
|
||||
);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength > MAX_MEDIA_BYTES) {
|
||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413);
|
||||
}
|
||||
return {
|
||||
bytes,
|
||||
|
||||
Reference in New Issue
Block a user