diff --git a/pugongying/xhs-pgy-export.user.js b/pugongying/xhs-pgy-export.user.js index 3cc0cd2..a200fc2 100644 --- a/pugongying/xhs-pgy-export.user.js +++ b/pugongying/xhs-pgy-export.user.js @@ -909,6 +909,236 @@ return createdFields; } + async function updateFeishuBitableTextField(options) { + const settings = options || {}; + const token = settings.tenantAccessToken; + const appToken = settings.appToken; + const tableId = settings.tableId; + const fieldId = settings.fieldId; + const fieldName = settings.fieldName; + if (!token || !appToken || !tableId || !fieldId || !fieldName) { + throw new Error("缺少飞书多维表格字段更新参数。"); + } + + const json = await feishuApiRequest( + `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( + tableId, + )}/fields/${encodeURIComponent(fieldId)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + }, + body: { + field_name: fieldName, + type: 1, + }, + fetchImpl: settings.fetchImpl, + actionName: `更新飞书多维表格字段 ${fieldName}`, + }, + ); + return json?.data?.field || json?.data || {}; + } + + async function listFeishuBitableFields(options) { + const settings = options || {}; + const token = settings.tenantAccessToken; + const appToken = settings.appToken; + const tableId = settings.tableId; + if (!token || !appToken || !tableId) { + throw new Error("缺少飞书多维表格字段查询参数。"); + } + + const json = await feishuApiRequest( + `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( + tableId, + )}/fields`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + fetchImpl: settings.fetchImpl, + actionName: "获取飞书多维表格字段", + }, + ); + return json?.data?.items || json?.data?.fields || []; + } + + function pickReusableFeishuBitablePrimaryField(fields) { + const list = Array.isArray(fields) ? fields : []; + return ( + list.find((field) => field.is_primary || field.isPrimary) || + list.find((field) => field.field_id || field.fieldId || field.id) || + null + ); + } + + async function prepareFeishuBitableFields(options) { + const settings = options || {}; + const fields = Array.isArray(settings.fields) ? settings.fields : []; + if (!fields.length) { + return []; + } + + const existingFields = await listFeishuBitableFields(settings); + const reusableField = pickReusableFeishuBitablePrimaryField(existingFields); + const createdFields = []; + let remainingFields = fields.slice(); + + if (reusableField) { + const fieldId = reusableField.field_id || reusableField.fieldId || reusableField.id; + createdFields.push( + await updateFeishuBitableTextField({ + ...settings, + fieldId, + fieldName: getFieldLabel(fields[0]), + }), + ); + remainingFields = fields.slice(1); + } + + createdFields.push( + ...(await createFeishuBitableTextFields({ + ...settings, + fields: remainingFields, + })), + ); + return createdFields; + } + + async function deleteFeishuBitableField(options) { + const settings = options || {}; + const token = settings.tenantAccessToken; + const appToken = settings.appToken; + const tableId = settings.tableId; + const fieldId = settings.fieldId; + if (!token || !appToken || !tableId || !fieldId) { + throw new Error("缺少飞书多维表格字段删除参数。"); + } + + await feishuApiRequest( + `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( + tableId, + )}/fields/${encodeURIComponent(fieldId)}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + fetchImpl: settings.fetchImpl, + actionName: "删除飞书多维表格默认字段", + }, + ); + } + + async function deleteFeishuBitableExtraFields(options) { + const settings = options || {}; + const selectedLabels = new Set( + (Array.isArray(settings.fields) ? settings.fields : []).map((field) => + getFieldLabel(field), + ), + ); + const fields = await listFeishuBitableFields(settings); + const removableFields = fields.filter((field) => { + const fieldName = field.field_name || field.fieldName || field.name; + const fieldId = field.field_id || field.fieldId || field.id; + return fieldId && fieldName && !selectedLabels.has(fieldName); + }); + + for (const field of removableFields) { + await deleteFeishuBitableField({ + ...settings, + fieldId: field.field_id || field.fieldId || field.id, + }); + } + return { deletedCount: removableFields.length }; + } + + async function listFeishuBitableRecords(options) { + const settings = options || {}; + const token = settings.tenantAccessToken; + const appToken = settings.appToken; + const tableId = settings.tableId; + if (!token || !appToken || !tableId) { + throw new Error("缺少飞书多维表格记录查询参数。"); + } + + const records = []; + let pageToken = ""; + do { + const suffix = pageToken ? `?page_token=${encodeURIComponent(pageToken)}` : ""; + const json = await feishuApiRequest( + `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( + tableId, + )}/records${suffix}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + fetchImpl: settings.fetchImpl, + actionName: "获取飞书多维表格默认记录", + }, + ); + const data = json?.data || {}; + records.push(...(data.items || data.records || [])); + pageToken = data.has_more ? data.page_token || "" : ""; + } while (pageToken); + + return records; + } + + async function deleteFeishuBitableRecords(options) { + const settings = options || {}; + const token = settings.tenantAccessToken; + const appToken = settings.appToken; + const tableId = settings.tableId; + const recordIds = Array.isArray(settings.recordIds) ? settings.recordIds : []; + if (!token || !appToken || !tableId) { + throw new Error("缺少飞书多维表格记录删除参数。"); + } + if (!recordIds.length) { + return { deletedCount: 0 }; + } + + const batchSize = 500; + let deletedCount = 0; + for (let index = 0; index < recordIds.length; index += batchSize) { + const batch = recordIds.slice(index, index + batchSize); + await feishuApiRequest( + `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( + tableId, + )}/records/batch_delete`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: { + records: batch, + }, + fetchImpl: settings.fetchImpl, + actionName: "删除飞书多维表格默认记录", + }, + ); + deletedCount += batch.length; + } + + return { deletedCount }; + } + + async function deleteFeishuBitableDefaultRecords(options) { + const records = await listFeishuBitableRecords(options); + const recordIds = records + .map((record) => record.record_id || record.recordId || record.id) + .filter(Boolean); + return deleteFeishuBitableRecords({ + ...options, + recordIds, + }); + } + async function writeFeishuBitableRecords(options) { const settings = options || {}; const token = settings.tenantAccessToken; @@ -970,13 +1200,26 @@ title: settings.title, fetchImpl, }); - await createFeishuBitableTextFields({ + await prepareFeishuBitableFields({ tenantAccessToken, appToken: bitable.appToken, tableId: bitable.tableId, fields, fetchImpl, }); + await deleteFeishuBitableExtraFields({ + tenantAccessToken, + appToken: bitable.appToken, + tableId: bitable.tableId, + fields, + fetchImpl, + }); + await deleteFeishuBitableDefaultRecords({ + tenantAccessToken, + appToken: bitable.appToken, + tableId: bitable.tableId, + fetchImpl, + }); const bitableRecords = buildFeishuBitableRecords(records, fields); const writeResult = await writeFeishuBitableRecords({ tenantAccessToken, @@ -2444,6 +2687,10 @@ createFeishuBitableTextFields, createExportController, createFeishuSpreadsheet, + deleteFeishuBitableDefaultRecords, + deleteFeishuBitableExtraFields, + deleteFeishuBitableField, + deleteFeishuBitableRecords, extractBloggerId, exportRecordsToFeishuBitable, exportRecordsToFeishuSpreadsheet, @@ -2452,7 +2699,11 @@ getFieldLabel, getFeishuFirstSheetId, getFeishuTenantAccessToken, + listFeishuBitableFields, + listFeishuBitableRecords, parseCreatorInputs, + prepareFeishuBitableFields, + updateFeishuBitableTextField, writeFeishuBitableRecords, writeFeishuSheetValues, }; diff --git a/pugongying/xhs-pgy-export.user.test.js b/pugongying/xhs-pgy-export.user.test.js index 3da34e2..5599d9a 100644 --- a/pugongying/xhs-pgy-export.user.test.js +++ b/pugongying/xhs-pgy-export.user.test.js @@ -195,6 +195,7 @@ test("exportRecordsToFeishuSpreadsheet creates spreadsheet then writes values", test("exportRecordsToFeishuBitable creates app fields and records", async () => { const calls = []; + let fieldsGetCount = 0; async function fetchImpl(url, options) { const body = options && options.body ? JSON.parse(options.body) : null; @@ -220,9 +221,57 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () => }, }); } - if (String(url).includes("/fields")) { + if (String(url).endsWith("/fields") && options.method === "POST") { return okJson({ code: 0, data: { field: { field_id: "fld-created" } } }); } + if (String(url).includes("/fields/") && options.method === "PUT") { + return okJson({ code: 0, data: { field: { field_id: "fld-default-text" } } }); + } + if (String(url).endsWith("/fields") && options.method === "GET") { + fieldsGetCount += 1; + if (fieldsGetCount === 1) { + return okJson({ + code: 0, + data: { + items: [ + { field_id: "fld-default-text", field_name: "文本", is_primary: true }, + { field_id: "fld-default-select", field_name: "单选" }, + { field_id: "fld-default-date", field_name: "日期" }, + { field_id: "fld-default-attachment", field_name: "附件" }, + ], + }, + }); + } + return okJson({ + code: 0, + data: { + items: [ + { field_id: "fld-default-text", field_name: "达人ID", is_primary: true }, + { field_id: "fld-default-select", field_name: "单选" }, + { field_id: "fld-default-date", field_name: "日期" }, + { field_id: "fld-default-attachment", field_name: "附件" }, + { field_id: "fld-name", field_name: "达人昵称" }, + ], + }, + }); + } + if (String(url).includes("/fields/")) { + return okJson({ code: 0, data: {} }); + } + if (String(url).endsWith("/records") && options.method === "GET") { + return okJson({ + code: 0, + data: { + items: Array.from({ length: 10 }, (_, index) => ({ + record_id: `rec-empty-${index + 1}`, + fields: {}, + })), + }, + }); + } + if (String(url).includes("/records/batch_delete")) { + return okJson({ code: 0, data: {} }); + } if (String(url).includes("/records/batch_create")) { return okJson({ code: 0, data: { records: [{ record_id: "rec-1" }] } }); } @@ -250,7 +299,7 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () => 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.equal(calls.length, 12); assert.deepEqual(calls[0].body, { app_id: "cli_xxx", app_secret: "secret", @@ -259,21 +308,56 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () => name: "测试多维表格", }); assert.equal(calls[2].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true); - assert.deepEqual(calls[2].body, { + assert.equal(calls[2].method, "GET"); + assert.equal(calls[3].url.endsWith("/fields/fld-default-text"), true); + assert.equal(calls[3].method, "PUT"); + assert.deepEqual(calls[3].body, { field_name: "达人ID", type: 1, }); - assert.deepEqual(calls[3].body, { + assert.equal(calls[4].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true); + assert.equal(calls[4].method, "POST"); + assert.deepEqual(calls[4].body, { field_name: "达人昵称", type: 1, }); + assert.equal(calls[5].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true); + assert.equal(calls[5].method, "GET"); + assert.equal(calls[6].url.endsWith("/fields/fld-default-select"), true); + assert.equal(calls[6].method, "DELETE"); + assert.equal(calls[7].url.endsWith("/fields/fld-default-date"), true); + assert.equal(calls[7].method, "DELETE"); + assert.equal(calls[8].url.endsWith("/fields/fld-default-attachment"), true); + assert.equal(calls[8].method, "DELETE"); + assert.equal(calls[9].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/records"), true); + assert.equal(calls[9].method, "GET"); assert.equal( - calls[4].url.endsWith( + calls[10].url.endsWith( + "/bitable/v1/apps/base-token/tables/tbl-default/records/batch_delete", + ), + true, + ); + assert.deepEqual(calls[10].body, { + records: [ + "rec-empty-1", + "rec-empty-2", + "rec-empty-3", + "rec-empty-4", + "rec-empty-5", + "rec-empty-6", + "rec-empty-7", + "rec-empty-8", + "rec-empty-9", + "rec-empty-10", + ], + }); + assert.equal( + calls[11].url.endsWith( "/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create", ), true, ); - assert.deepEqual(calls[4].body, { + assert.deepEqual(calls[11].body, { records: [ { fields: {