feat: add Feishu bitable export option
This commit is contained in:
parent
1f4297c883
commit
baaa4d5d81
@ -1,9 +1,9 @@
|
||||
// ==UserScript==
|
||||
// @name 小红书蒲公英达人信息导出
|
||||
// @namespace https://pgy.xiaohongshu.com/
|
||||
// @version 0.1.2
|
||||
// @version 0.1.3
|
||||
// @author wangxuesheng
|
||||
// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx 或飞书电子表格
|
||||
// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx、飞书电子表格或飞书多维表格
|
||||
// @match https://pgy.xiaohongshu.com/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @connect api.internal.intelligrow.cn
|
||||
@ -260,29 +260,51 @@
|
||||
return baseTarget;
|
||||
}
|
||||
|
||||
function extractIdFromHtml(html) {
|
||||
const text = normalizeScalar(html);
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const userIdPatterns = [
|
||||
/"user"\s*:\s*\{[^{}]*"id"\s*:\s*"([0-9a-f]{24})"/i,
|
||||
/"userId"\s*:\s*"([0-9a-f]{24})"/i,
|
||||
/"realUserId"\s*:\s*"([0-9a-f]{24})"/i,
|
||||
];
|
||||
for (const pattern of userIdPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveShortUrl(url) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof GM_xmlhttpRequest !== "function") {
|
||||
resolve(url);
|
||||
resolve({ url, html: "" });
|
||||
return;
|
||||
}
|
||||
GM_xmlhttpRequest({
|
||||
method: "GET",
|
||||
url,
|
||||
onload(res) {
|
||||
const html = res.responseText || "";
|
||||
if (res.finalUrl && res.finalUrl !== url) {
|
||||
resolve(res.finalUrl);
|
||||
resolve({ url: res.finalUrl, html });
|
||||
return;
|
||||
}
|
||||
const match = res.responseText && res.responseText.match(/href="([^"]+)"/);
|
||||
const match = html && html.match(/href="([^"]+)"/);
|
||||
if (match) {
|
||||
resolve(match[1].replace(/&/g, "&"));
|
||||
resolve({ url: match[1].replace(/&/g, "&"), html });
|
||||
} else {
|
||||
resolve(url);
|
||||
resolve({ url, html });
|
||||
}
|
||||
},
|
||||
onerror() {
|
||||
resolve(url);
|
||||
resolve({ url, html: "" });
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -341,12 +363,17 @@
|
||||
}
|
||||
|
||||
if (SHORT_LINK_HOSTS.some((h) => parsedUrl.hostname.endsWith(h))) {
|
||||
const realUrl = await resolveShortUrl(raw);
|
||||
const resolved = await resolveShortUrl(raw);
|
||||
const realUrl = typeof resolved === "string" ? resolved : resolved.url;
|
||||
try {
|
||||
return extractIdFromUrl(new URL(realUrl));
|
||||
} catch (error) {
|
||||
return "";
|
||||
const resolvedId = extractIdFromUrl(new URL(realUrl));
|
||||
if (resolvedId) {
|
||||
return resolvedId;
|
||||
}
|
||||
} catch (error) {
|
||||
return extractIdFromHtml(resolved.html);
|
||||
}
|
||||
return extractIdFromHtml(resolved.html);
|
||||
}
|
||||
|
||||
return "";
|
||||
@ -506,6 +533,15 @@
|
||||
return { value: json };
|
||||
}
|
||||
|
||||
function appendQueryParam(url, key, value) {
|
||||
const safeValue = normalizeScalar(value);
|
||||
if (!safeValue) {
|
||||
return url;
|
||||
}
|
||||
const separator = String(url).includes("?") ? "&" : "?";
|
||||
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(safeValue)}`;
|
||||
}
|
||||
|
||||
async function fetchBloggerRecord(id, fetchImpl) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("当前环境不支持 fetch,无法请求达人数据。");
|
||||
@ -537,7 +573,9 @@
|
||||
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
|
||||
const hasExtra = Object.keys(extra).length > 0;
|
||||
const fetcher = hasExtra && hasGmRequest() ? gmFetch : fetchImpl;
|
||||
const response = await fetcher(config.buildUrl(userId), {
|
||||
const cookie = extra["X-Cookie"] || extra["x-cookie"];
|
||||
const url = appendQueryParam(config.buildUrl(userId), "cookie", cookie);
|
||||
const response = await fetcher(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
@ -785,6 +823,176 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildFeishuBitableRecords(records, selectedFields) {
|
||||
const fields = Array.isArray(selectedFields) ? selectedFields : [];
|
||||
const labels = fields.map((field) => getFieldLabel(field));
|
||||
const list = Array.isArray(records) ? records : [];
|
||||
return list.map((record) => {
|
||||
const flattened = record && record.flattened ? record.flattened : {};
|
||||
const row = {};
|
||||
fields.forEach((field, index) => {
|
||||
row[labels[index]] = normalizeScalar(flattened[field]);
|
||||
});
|
||||
return { fields: row };
|
||||
});
|
||||
}
|
||||
|
||||
async function createFeishuBitableApp(options) {
|
||||
const settings = options || {};
|
||||
const token = settings.tenantAccessToken;
|
||||
if (!token) {
|
||||
throw new Error("缺少飞书 tenant_access_token,无法创建多维表格。");
|
||||
}
|
||||
|
||||
const json = await feishuApiRequest("/bitable/v1/apps", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
name: settings.title || `蒲公英达人导出-${formatTimestamp(new Date())}`,
|
||||
},
|
||||
fetchImpl: settings.fetchImpl,
|
||||
actionName: "创建飞书多维表格",
|
||||
});
|
||||
|
||||
const app = json?.data?.app || json?.data || {};
|
||||
const appToken = app.app_token || app.appToken || json?.data?.app_token;
|
||||
const tableId =
|
||||
app.default_table_id || app.defaultTableId || json?.data?.default_table_id;
|
||||
if (!appToken) {
|
||||
throw new Error("创建飞书多维表格失败:响应中缺少 app_token。");
|
||||
}
|
||||
if (!tableId) {
|
||||
throw new Error("创建飞书多维表格失败:响应中缺少 default_table_id。");
|
||||
}
|
||||
|
||||
return {
|
||||
appToken,
|
||||
tableId,
|
||||
url: app.url || json?.data?.url || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function createFeishuBitableTextFields(options) {
|
||||
const settings = options || {};
|
||||
const token = settings.tenantAccessToken;
|
||||
const appToken = settings.appToken;
|
||||
const tableId = settings.tableId;
|
||||
const fields = Array.isArray(settings.fields) ? settings.fields : [];
|
||||
if (!token || !appToken || !tableId) {
|
||||
throw new Error("缺少飞书多维表格字段创建参数。");
|
||||
}
|
||||
|
||||
const createdFields = [];
|
||||
for (const field of fields) {
|
||||
const label = getFieldLabel(field);
|
||||
const json = await feishuApiRequest(
|
||||
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
|
||||
tableId,
|
||||
)}/fields`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
field_name: label,
|
||||
type: 1,
|
||||
},
|
||||
fetchImpl: settings.fetchImpl,
|
||||
actionName: `创建飞书多维表格字段 ${label}`,
|
||||
},
|
||||
);
|
||||
createdFields.push(json?.data?.field || json?.data || {});
|
||||
}
|
||||
return createdFields;
|
||||
}
|
||||
|
||||
async function writeFeishuBitableRecords(options) {
|
||||
const settings = options || {};
|
||||
const token = settings.tenantAccessToken;
|
||||
const appToken = settings.appToken;
|
||||
const tableId = settings.tableId;
|
||||
const records = Array.isArray(settings.records) ? settings.records : [];
|
||||
if (!token || !appToken || !tableId) {
|
||||
throw new Error("缺少飞书多维表格写入参数。");
|
||||
}
|
||||
if (!records.length) {
|
||||
throw new Error("没有可写入飞书多维表格的数据。");
|
||||
}
|
||||
|
||||
const batchSize = 500;
|
||||
let writtenCount = 0;
|
||||
for (let index = 0; index < records.length; index += batchSize) {
|
||||
const batch = records.slice(index, index + batchSize);
|
||||
await feishuApiRequest(
|
||||
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
|
||||
tableId,
|
||||
)}/records/batch_create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
records: batch,
|
||||
},
|
||||
fetchImpl: settings.fetchImpl,
|
||||
actionName: "写入飞书多维表格",
|
||||
},
|
||||
);
|
||||
writtenCount += batch.length;
|
||||
}
|
||||
|
||||
return { writtenCount };
|
||||
}
|
||||
|
||||
async function exportRecordsToFeishuBitable(options) {
|
||||
const settings = options || {};
|
||||
const records = Array.isArray(settings.records) ? settings.records : [];
|
||||
const fields = Array.isArray(settings.fields) ? settings.fields : [];
|
||||
if (!records.length) {
|
||||
throw new Error("没有可导出的达人数据,请先读取数据。");
|
||||
}
|
||||
if (!fields.length) {
|
||||
throw new Error("请至少勾选一个导出字段。");
|
||||
}
|
||||
|
||||
const fetchImpl = settings.fetchImpl;
|
||||
const tenantAccessToken = await getFeishuTenantAccessToken({
|
||||
appId: settings.appId,
|
||||
appSecret: settings.appSecret,
|
||||
fetchImpl,
|
||||
});
|
||||
const bitable = await createFeishuBitableApp({
|
||||
tenantAccessToken,
|
||||
title: settings.title,
|
||||
fetchImpl,
|
||||
});
|
||||
await createFeishuBitableTextFields({
|
||||
tenantAccessToken,
|
||||
appToken: bitable.appToken,
|
||||
tableId: bitable.tableId,
|
||||
fields,
|
||||
fetchImpl,
|
||||
});
|
||||
const bitableRecords = buildFeishuBitableRecords(records, fields);
|
||||
const writeResult = await writeFeishuBitableRecords({
|
||||
tenantAccessToken,
|
||||
appToken: bitable.appToken,
|
||||
tableId: bitable.tableId,
|
||||
records: bitableRecords,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
return {
|
||||
...bitable,
|
||||
rowCount: records.length,
|
||||
writtenCount: writeResult.writtenCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper, onDone) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
if (!list.length) {
|
||||
@ -997,6 +1205,37 @@
|
||||
return result;
|
||||
},
|
||||
|
||||
async exportFeishuBitable(selectedFields, onProgress) {
|
||||
if (!cachedRecords.length) {
|
||||
throw new Error("请先读取字段并确认达人数据。");
|
||||
}
|
||||
|
||||
const fields =
|
||||
Array.isArray(selectedFields) && selectedFields.length
|
||||
? selectedFields
|
||||
: cachedFields.map((field) => field.path);
|
||||
|
||||
const report = (percentage, message) => {
|
||||
if (typeof onProgress !== "function") {
|
||||
return;
|
||||
}
|
||||
onProgress(Math.max(0, Math.min(100, percentage)), message || "");
|
||||
};
|
||||
|
||||
report(0, "正在获取飞书应用访问凭证...");
|
||||
const credentials = resolveFeishuCredentials(settings);
|
||||
const result = await exportRecordsToFeishuBitable({
|
||||
appId: credentials.appId,
|
||||
appSecret: credentials.appSecret,
|
||||
title: `蒲公英达人导出-${formatTimestamp(now())}`,
|
||||
records: cachedRecords,
|
||||
fields,
|
||||
fetchImpl: hasGmRequest() ? undefined : fetchImpl,
|
||||
});
|
||||
report(100, "已写入飞书多维表格");
|
||||
return result;
|
||||
},
|
||||
|
||||
getState() {
|
||||
return {
|
||||
records: cachedRecords.slice(),
|
||||
@ -1226,6 +1465,46 @@
|
||||
color: #2e211a;
|
||||
}
|
||||
|
||||
.xhs-export-radio-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xhs-export-radio {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xhs-export-radio-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xhs-export-radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(141, 88, 51, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #5e412f;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xhs-export-radio-input:checked + .xhs-export-radio-label {
|
||||
border-color: transparent;
|
||||
color: #fff8ef;
|
||||
background: linear-gradient(135deg, #ef6a00, #d72638);
|
||||
}
|
||||
|
||||
.xhs-export-actions,
|
||||
.xhs-export-mini-actions {
|
||||
display: flex;
|
||||
@ -1652,6 +1931,19 @@
|
||||
<button class="xhs-export-select-trigger" type="button" data-action="toggle-feishu-config" aria-expanded="false">飞书导出配置</button>
|
||||
<div class="xhs-export-config-panel">
|
||||
<div class="xhs-export-config-grid">
|
||||
<div class="xhs-export-config-field">
|
||||
<span class="xhs-export-config-label">飞书导出格式</span>
|
||||
<div class="xhs-export-radio-group" role="radiogroup" aria-label="飞书导出格式">
|
||||
<label class="xhs-export-radio">
|
||||
<input class="xhs-export-radio-input" type="radio" name="xhs-export-feishu-type" value="spreadsheet" checked>
|
||||
<span class="xhs-export-radio-label">电子表格</span>
|
||||
</label>
|
||||
<label class="xhs-export-radio">
|
||||
<input class="xhs-export-radio-input" type="radio" name="xhs-export-feishu-type" value="bitable">
|
||||
<span class="xhs-export-radio-label">多维表格</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="xhs-export-config-field">
|
||||
<span class="xhs-export-config-label">飞书 app_id</span>
|
||||
<input class="xhs-export-config-input" data-config="feishu-app-id" type="text" autocomplete="off" placeholder="cli_xxx">
|
||||
@ -1712,6 +2004,7 @@
|
||||
feishuConfigTrigger: panel.querySelector('[data-action="toggle-feishu-config"]'),
|
||||
feishuAppIdInput: panel.querySelector('[data-config="feishu-app-id"]'),
|
||||
feishuAppSecretInput: panel.querySelector('[data-config="feishu-app-secret"]'),
|
||||
feishuTypeInputs: panel.querySelectorAll('input[name="xhs-export-feishu-type"]'),
|
||||
localExportButton: panel.querySelector('[data-action="export-local"]'),
|
||||
feishuExportButton: panel.querySelector('[data-action="export-feishu"]'),
|
||||
status: panel.querySelector(".xhs-export-status"),
|
||||
@ -1860,6 +2153,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedFeishuExportType(refs) {
|
||||
const selected =
|
||||
refs && refs.feishuTypeInputs
|
||||
? Array.from(refs.feishuTypeInputs).find((input) => input.checked)
|
||||
: null;
|
||||
return selected && selected.value === "bitable" ? "bitable" : "spreadsheet";
|
||||
}
|
||||
|
||||
function getFeishuExportTypeLabel(type) {
|
||||
return type === "bitable" ? "飞书多维表格" : "飞书电子表格";
|
||||
}
|
||||
|
||||
function setStatus(node, message, isError) {
|
||||
node.textContent = message;
|
||||
node.classList.toggle("is-error", Boolean(isError));
|
||||
@ -1933,8 +2238,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildFeishuSuccessModalContent(result) {
|
||||
function buildFeishuSuccessModalContent(result, type) {
|
||||
const fragment = root.document.createDocumentFragment();
|
||||
const label = getFeishuExportTypeLabel(type);
|
||||
const rowCount = result && result.rowCount;
|
||||
fragment.appendChild(root.document.createTextNode(`已导出 ${rowCount} 条数据:`));
|
||||
|
||||
@ -1946,13 +2252,17 @@
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = "打开飞书电子表格";
|
||||
link.textContent = `打开${label}`;
|
||||
fragment.appendChild(link);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
const token =
|
||||
type === "bitable"
|
||||
? (result && result.appToken) || ""
|
||||
: (result && result.spreadsheetToken) || "";
|
||||
fragment.appendChild(
|
||||
root.document.createTextNode(`表格 token:${(result && result.spreadsheetToken) || ""}`),
|
||||
root.document.createTextNode(`表格 token:${token}`),
|
||||
);
|
||||
return fragment;
|
||||
}
|
||||
@ -2050,6 +2360,8 @@
|
||||
if (!checkedFields.length) {
|
||||
throw new Error("请至少勾选一个导出字段。");
|
||||
}
|
||||
const exportType = getSelectedFeishuExportType(refs);
|
||||
const exportLabel = getFeishuExportTypeLabel(exportType);
|
||||
|
||||
setExportButtonsDisabled(refs, true);
|
||||
hideProgress(refs);
|
||||
@ -2058,24 +2370,31 @@
|
||||
setStatus(refs.status, "正在读取达人数据,请稍候...", false);
|
||||
|
||||
await prepareExportData(controller, refs, checkedFields, 40);
|
||||
setStatus(refs.status, "正在创建飞书电子表格并写入数据...", false);
|
||||
const result = await controller.exportFeishuSpreadsheet(
|
||||
setStatus(refs.status, `正在创建${exportLabel}并写入数据...`, false);
|
||||
const exportMethod =
|
||||
exportType === "bitable"
|
||||
? controller.exportFeishuBitable
|
||||
: controller.exportFeishuSpreadsheet;
|
||||
const result = await exportMethod.call(
|
||||
controller,
|
||||
checkedFields,
|
||||
(percentage, message) =>
|
||||
setProgress(
|
||||
refs,
|
||||
40 + Math.floor((percentage * 60) / 100),
|
||||
message || "正在写入飞书电子表格...",
|
||||
message || `正在写入${exportLabel}...`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
setProgress(refs, 100, "已写入飞书电子表格", false);
|
||||
openModal(refs, buildFeishuSuccessModalContent(result));
|
||||
setProgress(refs, 100, `已写入${exportLabel}`, false);
|
||||
openModal(refs, buildFeishuSuccessModalContent(result, exportType));
|
||||
setStatus(
|
||||
refs.status,
|
||||
result.url
|
||||
? `已导出 ${result.rowCount} 条达人数据到飞书:${result.url}`
|
||||
: `已导出 ${result.rowCount} 条达人数据到飞书,表格 token:${result.spreadsheetToken}`,
|
||||
? `已导出 ${result.rowCount} 条达人数据到${exportLabel}:${result.url}`
|
||||
: `已导出 ${result.rowCount} 条达人数据到${exportLabel},表格 token:${
|
||||
exportType === "bitable" ? result.appToken : result.spreadsheetToken
|
||||
}`,
|
||||
false,
|
||||
);
|
||||
} catch (error) {
|
||||
@ -2117,12 +2436,16 @@
|
||||
return {
|
||||
API_BASE,
|
||||
buildExportRows,
|
||||
buildFeishuBitableRecords,
|
||||
buildFeishuRange,
|
||||
buildFeishuSheetValues,
|
||||
buildFieldOptions,
|
||||
createFeishuBitableApp,
|
||||
createFeishuBitableTextFields,
|
||||
createExportController,
|
||||
createFeishuSpreadsheet,
|
||||
extractBloggerId,
|
||||
exportRecordsToFeishuBitable,
|
||||
exportRecordsToFeishuSpreadsheet,
|
||||
fetchMergedBloggerRecord,
|
||||
flattenRecord,
|
||||
@ -2130,6 +2453,7 @@
|
||||
getFeishuFirstSheetId,
|
||||
getFeishuTenantAccessToken,
|
||||
parseCreatorInputs,
|
||||
writeFeishuBitableRecords,
|
||||
writeFeishuSheetValues,
|
||||
};
|
||||
});
|
||||
|
||||
@ -37,6 +37,52 @@ test("data_summary request includes business=1", async () => {
|
||||
assert.equal(parsed.searchParams.get("userId"), "u-123");
|
||||
});
|
||||
|
||||
test("supplemental proxy requests include cookie query parameter", async () => {
|
||||
const urls = [];
|
||||
|
||||
async function fetchImpl(url) {
|
||||
urls.push(String(url));
|
||||
if (String(url).startsWith(api.API_BASE)) {
|
||||
return okJson({ data: { userId: "u-123" } });
|
||||
}
|
||||
return okJson({ data: {} });
|
||||
}
|
||||
|
||||
await api.fetchMergedBloggerRecord("any-id", fetchImpl);
|
||||
|
||||
const proxyUrls = urls.filter((url) => url.includes("/v1/pugongying/"));
|
||||
assert.equal(proxyUrls.length, 2);
|
||||
for (const url of proxyUrls) {
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.searchParams.get("cookie"), "x=y");
|
||||
}
|
||||
});
|
||||
|
||||
test("extractBloggerId resolves xhs short link from SSR profile HTML", async () => {
|
||||
const previousGm = global.GM_xmlhttpRequest;
|
||||
global.GM_xmlhttpRequest = (options) => {
|
||||
assert.equal(options.url, "https://xhslink.com/m/example");
|
||||
options.onload({
|
||||
status: 200,
|
||||
finalUrl:
|
||||
"https://www.xiaohongshu.com/user/profile/2022919XN?xsec_source=app_share",
|
||||
responseText:
|
||||
'<script>window.__INITIAL_STATE__={"profile":{"noteData":[{"user":{"id":"60379f3c000000000101e53f","nickname":"Alice"}}]}}</script>',
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.doesNotReject(async () => {
|
||||
assert.equal(
|
||||
await api.extractBloggerId("https://xhslink.com/m/example"),
|
||||
"60379f3c000000000101e53f",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
global.GM_xmlhttpRequest = previousGm;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildFeishuSheetValues uses labels as header row", () => {
|
||||
const records = [
|
||||
{
|
||||
@ -147,6 +193,128 @@ test("exportRecordsToFeishuSpreadsheet creates spreadsheet then writes values",
|
||||
});
|
||||
});
|
||||
|
||||
test("exportRecordsToFeishuBitable creates app fields and records", async () => {
|
||||
const calls = [];
|
||||
|
||||
async function fetchImpl(url, options) {
|
||||
const body = options && options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({
|
||||
url: String(url),
|
||||
method: options && options.method,
|
||||
headers: options && options.headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (String(url).includes("/auth/v3/tenant_access_token/internal")) {
|
||||
return okJson({ code: 0, tenant_access_token: "tenant-token" });
|
||||
}
|
||||
if (String(url).endsWith("/bitable/v1/apps")) {
|
||||
return okJson({
|
||||
code: 0,
|
||||
data: {
|
||||
app: {
|
||||
app_token: "base-token",
|
||||
default_table_id: "tbl-default",
|
||||
url: "https://feishu.example/base-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (String(url).includes("/fields")) {
|
||||
return okJson({ code: 0, data: { field: { field_id: "fld-created" } } });
|
||||
}
|
||||
if (String(url).includes("/records/batch_create")) {
|
||||
return okJson({ code: 0, data: { records: [{ record_id: "rec-1" }] } });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
}
|
||||
|
||||
const result = await api.exportRecordsToFeishuBitable({
|
||||
appId: "cli_xxx",
|
||||
appSecret: "secret",
|
||||
title: "测试多维表格",
|
||||
records: [
|
||||
{
|
||||
flattened: {
|
||||
userId: "u-1",
|
||||
name: "达人 A",
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: ["userId", "name"],
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.equal(result.appToken, "base-token");
|
||||
assert.equal(result.tableId, "tbl-default");
|
||||
assert.equal(result.url, "https://feishu.example/base-token");
|
||||
assert.equal(result.rowCount, 1);
|
||||
assert.equal(calls.length, 5);
|
||||
assert.deepEqual(calls[0].body, {
|
||||
app_id: "cli_xxx",
|
||||
app_secret: "secret",
|
||||
});
|
||||
assert.deepEqual(calls[1].body, {
|
||||
name: "测试多维表格",
|
||||
});
|
||||
assert.equal(calls[2].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
|
||||
assert.deepEqual(calls[2].body, {
|
||||
field_name: "达人ID",
|
||||
type: 1,
|
||||
});
|
||||
assert.deepEqual(calls[3].body, {
|
||||
field_name: "达人昵称",
|
||||
type: 1,
|
||||
});
|
||||
assert.equal(
|
||||
calls[4].url.endsWith(
|
||||
"/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls[4].body, {
|
||||
records: [
|
||||
{
|
||||
fields: {
|
||||
"达人ID": "u-1",
|
||||
"达人昵称": "达人 A",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("writeFeishuBitableRecords writes records in 500-row batches", async () => {
|
||||
const batches = [];
|
||||
|
||||
async function fetchImpl(url, options) {
|
||||
assert.equal(
|
||||
String(url).endsWith("/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create"),
|
||||
true,
|
||||
);
|
||||
batches.push(JSON.parse(options.body).records.length);
|
||||
return okJson({ code: 0, data: {} });
|
||||
}
|
||||
|
||||
const records = Array.from({ length: 501 }, (_, index) => ({
|
||||
fields: {
|
||||
"达人ID": `u-${index + 1}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await api.writeFeishuBitableRecords({
|
||||
tenantAccessToken: "tenant-token",
|
||||
appToken: "base-token",
|
||||
tableId: "tbl-default",
|
||||
records,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.deepEqual(batches, [500, 1]);
|
||||
assert.equal(result.writtenCount, 501);
|
||||
});
|
||||
|
||||
test("controller reads Feishu credentials from localStorage instead of bundled secrets", async () => {
|
||||
const previousLocalStorage = global.localStorage;
|
||||
const previousGm = global.GM_xmlhttpRequest;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user