feat: add Feishu bitable export option
This commit is contained in:
parent
1f4297c883
commit
baaa4d5d81
@ -1,9 +1,9 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 小红书蒲公英达人信息导出
|
// @name 小红书蒲公英达人信息导出
|
||||||
// @namespace https://pgy.xiaohongshu.com/
|
// @namespace https://pgy.xiaohongshu.com/
|
||||||
// @version 0.1.2
|
// @version 0.1.3
|
||||||
// @author wangxuesheng
|
// @author wangxuesheng
|
||||||
// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx 或飞书电子表格
|
// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx、飞书电子表格或飞书多维表格
|
||||||
// @match https://pgy.xiaohongshu.com/*
|
// @match https://pgy.xiaohongshu.com/*
|
||||||
// @grant GM_xmlhttpRequest
|
// @grant GM_xmlhttpRequest
|
||||||
// @connect api.internal.intelligrow.cn
|
// @connect api.internal.intelligrow.cn
|
||||||
@ -260,29 +260,51 @@
|
|||||||
return baseTarget;
|
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) {
|
function resolveShortUrl(url) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (typeof GM_xmlhttpRequest !== "function") {
|
if (typeof GM_xmlhttpRequest !== "function") {
|
||||||
resolve(url);
|
resolve({ url, html: "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GM_xmlhttpRequest({
|
GM_xmlhttpRequest({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url,
|
url,
|
||||||
onload(res) {
|
onload(res) {
|
||||||
|
const html = res.responseText || "";
|
||||||
if (res.finalUrl && res.finalUrl !== url) {
|
if (res.finalUrl && res.finalUrl !== url) {
|
||||||
resolve(res.finalUrl);
|
resolve({ url: res.finalUrl, html });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const match = res.responseText && res.responseText.match(/href="([^"]+)"/);
|
const match = html && html.match(/href="([^"]+)"/);
|
||||||
if (match) {
|
if (match) {
|
||||||
resolve(match[1].replace(/&/g, "&"));
|
resolve({ url: match[1].replace(/&/g, "&"), html });
|
||||||
} else {
|
} else {
|
||||||
resolve(url);
|
resolve({ url, html });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onerror() {
|
onerror() {
|
||||||
resolve(url);
|
resolve({ url, html: "" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -341,12 +363,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (SHORT_LINK_HOSTS.some((h) => parsedUrl.hostname.endsWith(h))) {
|
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 {
|
try {
|
||||||
return extractIdFromUrl(new URL(realUrl));
|
const resolvedId = extractIdFromUrl(new URL(realUrl));
|
||||||
|
if (resolvedId) {
|
||||||
|
return resolvedId;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return "";
|
return extractIdFromHtml(resolved.html);
|
||||||
}
|
}
|
||||||
|
return extractIdFromHtml(resolved.html);
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@ -506,6 +533,15 @@
|
|||||||
return { value: json };
|
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) {
|
async function fetchBloggerRecord(id, fetchImpl) {
|
||||||
if (typeof fetchImpl !== "function") {
|
if (typeof fetchImpl !== "function") {
|
||||||
throw new Error("当前环境不支持 fetch,无法请求达人数据。");
|
throw new Error("当前环境不支持 fetch,无法请求达人数据。");
|
||||||
@ -537,7 +573,9 @@
|
|||||||
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
|
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
|
||||||
const hasExtra = Object.keys(extra).length > 0;
|
const hasExtra = Object.keys(extra).length > 0;
|
||||||
const fetcher = hasExtra && hasGmRequest() ? gmFetch : fetchImpl;
|
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",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
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) {
|
async function mapWithConcurrency(items, limit, mapper, onDone) {
|
||||||
const list = Array.isArray(items) ? items : [];
|
const list = Array.isArray(items) ? items : [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
@ -997,6 +1205,37 @@
|
|||||||
return result;
|
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() {
|
getState() {
|
||||||
return {
|
return {
|
||||||
records: cachedRecords.slice(),
|
records: cachedRecords.slice(),
|
||||||
@ -1226,6 +1465,46 @@
|
|||||||
color: #2e211a;
|
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-actions,
|
||||||
.xhs-export-mini-actions {
|
.xhs-export-mini-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1652,6 +1931,19 @@
|
|||||||
<button class="xhs-export-select-trigger" type="button" data-action="toggle-feishu-config" aria-expanded="false">飞书导出配置</button>
|
<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-panel">
|
||||||
<div class="xhs-export-config-grid">
|
<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">
|
<label class="xhs-export-config-field">
|
||||||
<span class="xhs-export-config-label">飞书 app_id</span>
|
<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">
|
<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"]'),
|
feishuConfigTrigger: panel.querySelector('[data-action="toggle-feishu-config"]'),
|
||||||
feishuAppIdInput: panel.querySelector('[data-config="feishu-app-id"]'),
|
feishuAppIdInput: panel.querySelector('[data-config="feishu-app-id"]'),
|
||||||
feishuAppSecretInput: panel.querySelector('[data-config="feishu-app-secret"]'),
|
feishuAppSecretInput: panel.querySelector('[data-config="feishu-app-secret"]'),
|
||||||
|
feishuTypeInputs: panel.querySelectorAll('input[name="xhs-export-feishu-type"]'),
|
||||||
localExportButton: panel.querySelector('[data-action="export-local"]'),
|
localExportButton: panel.querySelector('[data-action="export-local"]'),
|
||||||
feishuExportButton: panel.querySelector('[data-action="export-feishu"]'),
|
feishuExportButton: panel.querySelector('[data-action="export-feishu"]'),
|
||||||
status: panel.querySelector(".xhs-export-status"),
|
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) {
|
function setStatus(node, message, isError) {
|
||||||
node.textContent = message;
|
node.textContent = message;
|
||||||
node.classList.toggle("is-error", Boolean(isError));
|
node.classList.toggle("is-error", Boolean(isError));
|
||||||
@ -1933,8 +2238,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFeishuSuccessModalContent(result) {
|
function buildFeishuSuccessModalContent(result, type) {
|
||||||
const fragment = root.document.createDocumentFragment();
|
const fragment = root.document.createDocumentFragment();
|
||||||
|
const label = getFeishuExportTypeLabel(type);
|
||||||
const rowCount = result && result.rowCount;
|
const rowCount = result && result.rowCount;
|
||||||
fragment.appendChild(root.document.createTextNode(`已导出 ${rowCount} 条数据:`));
|
fragment.appendChild(root.document.createTextNode(`已导出 ${rowCount} 条数据:`));
|
||||||
|
|
||||||
@ -1946,13 +2252,17 @@
|
|||||||
link.href = url;
|
link.href = url;
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
link.rel = "noopener noreferrer";
|
link.rel = "noopener noreferrer";
|
||||||
link.textContent = "打开飞书电子表格";
|
link.textContent = `打开${label}`;
|
||||||
fragment.appendChild(link);
|
fragment.appendChild(link);
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token =
|
||||||
|
type === "bitable"
|
||||||
|
? (result && result.appToken) || ""
|
||||||
|
: (result && result.spreadsheetToken) || "";
|
||||||
fragment.appendChild(
|
fragment.appendChild(
|
||||||
root.document.createTextNode(`表格 token:${(result && result.spreadsheetToken) || ""}`),
|
root.document.createTextNode(`表格 token:${token}`),
|
||||||
);
|
);
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
@ -2050,6 +2360,8 @@
|
|||||||
if (!checkedFields.length) {
|
if (!checkedFields.length) {
|
||||||
throw new Error("请至少勾选一个导出字段。");
|
throw new Error("请至少勾选一个导出字段。");
|
||||||
}
|
}
|
||||||
|
const exportType = getSelectedFeishuExportType(refs);
|
||||||
|
const exportLabel = getFeishuExportTypeLabel(exportType);
|
||||||
|
|
||||||
setExportButtonsDisabled(refs, true);
|
setExportButtonsDisabled(refs, true);
|
||||||
hideProgress(refs);
|
hideProgress(refs);
|
||||||
@ -2058,24 +2370,31 @@
|
|||||||
setStatus(refs.status, "正在读取达人数据,请稍候...", false);
|
setStatus(refs.status, "正在读取达人数据,请稍候...", false);
|
||||||
|
|
||||||
await prepareExportData(controller, refs, checkedFields, 40);
|
await prepareExportData(controller, refs, checkedFields, 40);
|
||||||
setStatus(refs.status, "正在创建飞书电子表格并写入数据...", false);
|
setStatus(refs.status, `正在创建${exportLabel}并写入数据...`, false);
|
||||||
const result = await controller.exportFeishuSpreadsheet(
|
const exportMethod =
|
||||||
|
exportType === "bitable"
|
||||||
|
? controller.exportFeishuBitable
|
||||||
|
: controller.exportFeishuSpreadsheet;
|
||||||
|
const result = await exportMethod.call(
|
||||||
|
controller,
|
||||||
checkedFields,
|
checkedFields,
|
||||||
(percentage, message) =>
|
(percentage, message) =>
|
||||||
setProgress(
|
setProgress(
|
||||||
refs,
|
refs,
|
||||||
40 + Math.floor((percentage * 60) / 100),
|
40 + Math.floor((percentage * 60) / 100),
|
||||||
message || "正在写入飞书电子表格...",
|
message || `正在写入${exportLabel}...`,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setProgress(refs, 100, "已写入飞书电子表格", false);
|
setProgress(refs, 100, `已写入${exportLabel}`, false);
|
||||||
openModal(refs, buildFeishuSuccessModalContent(result));
|
openModal(refs, buildFeishuSuccessModalContent(result, exportType));
|
||||||
setStatus(
|
setStatus(
|
||||||
refs.status,
|
refs.status,
|
||||||
result.url
|
result.url
|
||||||
? `已导出 ${result.rowCount} 条达人数据到飞书:${result.url}`
|
? `已导出 ${result.rowCount} 条达人数据到${exportLabel}:${result.url}`
|
||||||
: `已导出 ${result.rowCount} 条达人数据到飞书,表格 token:${result.spreadsheetToken}`,
|
: `已导出 ${result.rowCount} 条达人数据到${exportLabel},表格 token:${
|
||||||
|
exportType === "bitable" ? result.appToken : result.spreadsheetToken
|
||||||
|
}`,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2117,12 +2436,16 @@
|
|||||||
return {
|
return {
|
||||||
API_BASE,
|
API_BASE,
|
||||||
buildExportRows,
|
buildExportRows,
|
||||||
|
buildFeishuBitableRecords,
|
||||||
buildFeishuRange,
|
buildFeishuRange,
|
||||||
buildFeishuSheetValues,
|
buildFeishuSheetValues,
|
||||||
buildFieldOptions,
|
buildFieldOptions,
|
||||||
|
createFeishuBitableApp,
|
||||||
|
createFeishuBitableTextFields,
|
||||||
createExportController,
|
createExportController,
|
||||||
createFeishuSpreadsheet,
|
createFeishuSpreadsheet,
|
||||||
extractBloggerId,
|
extractBloggerId,
|
||||||
|
exportRecordsToFeishuBitable,
|
||||||
exportRecordsToFeishuSpreadsheet,
|
exportRecordsToFeishuSpreadsheet,
|
||||||
fetchMergedBloggerRecord,
|
fetchMergedBloggerRecord,
|
||||||
flattenRecord,
|
flattenRecord,
|
||||||
@ -2130,6 +2453,7 @@
|
|||||||
getFeishuFirstSheetId,
|
getFeishuFirstSheetId,
|
||||||
getFeishuTenantAccessToken,
|
getFeishuTenantAccessToken,
|
||||||
parseCreatorInputs,
|
parseCreatorInputs,
|
||||||
|
writeFeishuBitableRecords,
|
||||||
writeFeishuSheetValues,
|
writeFeishuSheetValues,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -37,6 +37,52 @@ test("data_summary request includes business=1", async () => {
|
|||||||
assert.equal(parsed.searchParams.get("userId"), "u-123");
|
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", () => {
|
test("buildFeishuSheetValues uses labels as header row", () => {
|
||||||
const records = [
|
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 () => {
|
test("controller reads Feishu credentials from localStorage instead of bundled secrets", async () => {
|
||||||
const previousLocalStorage = global.localStorage;
|
const previousLocalStorage = global.localStorage;
|
||||||
const previousGm = global.GM_xmlhttpRequest;
|
const previousGm = global.GM_xmlhttpRequest;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user