feat: add Feishu bitable export option

This commit is contained in:
wxs 2026-05-12 12:30:01 +08:00
parent 1f4297c883
commit baaa4d5d81
2 changed files with 514 additions and 22 deletions

View File

@ -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,
};
});

View File

@ -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;