NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Feishu Bitable Failure Monitor
// @namespace https://your-domain.example
// @version 0.4.0
// @description 监控飞书多维表格失败任务并提供批量重试功能
// @match https://*.feishu.cn/base/*
// @match https://*.larkoffice.com/base/*
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ==================== 全局变量 ====================
const originalFetch = window.fetch.bind(window);
const failureStore = new Map(); // 存储失败任务: key = `${recordId}:${fieldId}`
let panelRoot, listContainer;
let lastRetryHeaders = {}; // 捕获的页面重试请求 headers
let isLoading = false; // 加载状态
// ==================== UI 面板 ====================
// 创建并初始化监控面板
function ensurePanel() {
if (panelRoot) return;
panelRoot = document.createElement('div');
panelRoot.id = 'bitable-failure-panel';
panelRoot.style.cssText = [
'position:fixed',
'right:24px',
'bottom:24px',
'width:340px',
'max-height:50vh',
'background:#1f2329',
'color:#fff',
'font-size:12px',
'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
'box-shadow:0 6px 16px rgba(0,0,0,0.32)',
'border-radius:8px',
'overflow:hidden',
'z-index:999999'
].join(';');
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#2c313a;';
header.innerHTML = '<strong style="font-weight:600;">失败任务监控</strong>';
const excludeBtn = document.createElement('button');
excludeBtn.textContent = '排除已有图片';
excludeBtn.style.cssText = 'background:#ff6b35;border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;margin-left:8px;';
excludeBtn.addEventListener('click', excludeExistingImages);
header.appendChild(excludeBtn);
listContainer = document.createElement('div');
listContainer.style.cssText = 'overflow-y:auto;max-height:calc(50vh - 40px);';
panelRoot.appendChild(header);
panelRoot.appendChild(listContainer);
document.body.appendChild(panelRoot);
}
// 渲染失败任务列表(按列分组)
function renderList() {
ensurePanel();
listContainer.innerHTML = '';
// 显示加载状态
if (isLoading) {
const loading = document.createElement('div');
loading.textContent = '查询中,请稍候...';
loading.style.cssText = 'padding:12px;color:#b3b8c2;text-align:center;';
listContainer.appendChild(loading);
return;
}
const entries = Array.from(failureStore.values());
// 空状态
if (entries.length === 0) {
const empty = document.createElement('div');
empty.textContent = '当前没有检测到失败项';
empty.style.cssText = 'padding:12px;color:#b3b8c2;';
listContainer.appendChild(empty);
return;
}
// 按修改时间排序并按列分组
entries.sort((a, b) => (b.modifiedTime || 0) - (a.modifiedTime || 0));
const grouped = {};
entries.forEach(entry => {
if (!grouped[entry.fieldId]) grouped[entry.fieldId] = [];
grouped[entry.fieldId].push(entry);
});
// 为每个列创建分组区块
Object.keys(grouped).forEach(fieldId => {
const fieldEntries = grouped[fieldId];
const fieldSection = document.createElement('div');
fieldSection.style.cssText = 'border:1px solid rgba(255,255,255,0.1);margin-bottom:10px;border-radius:4px;overflow:hidden;';
// 列头部 + 重试按钮
const fieldHeader = document.createElement('div');
fieldHeader.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#2c313a;font-weight:600;color:#ffd666;';
fieldHeader.textContent = `列 ${fieldId}`;
const retryBtn = document.createElement('button');
retryBtn.textContent = '重试全部';
retryBtn.style.cssText = 'background:#3a7afe;border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;';
retryBtn.addEventListener('click', () => retryField(fieldId, fieldEntries.map(e => e.recordId)));
fieldHeader.appendChild(retryBtn);
fieldSection.appendChild(fieldHeader);
// 记录列表
const recordList = document.createElement('div');
fieldEntries.forEach(entry => {
const recordItem = document.createElement('div');
recordItem.style.cssText = 'padding:6px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#b3b8c2;';
recordItem.textContent = `记录 ${entry.recordId}`;
recordList.appendChild(recordItem);
});
fieldSection.appendChild(recordList);
listContainer.appendChild(fieldSection);
});
}
// ==================== 数据处理 ====================
// 处理拦截到的响应,提取失败任务
function handleFailurePayload(context) {
const { responseBody } = context;
const cellTagMap = responseBody?.data?.cellTagMap || {};
let hasChange = false;
Object.entries(cellTagMap).forEach(([recordId, fields]) => {
Object.entries(fields || {}).forEach(([fieldId, info]) => {
const logId = info?.logID;
const key = `${recordId}:${fieldId}`;
const shouldKeep = logId && logId.trim() !== ''; // logID 不为空表示失败
if (shouldKeep) {
console.log(`检测到失败: 行ID=${recordId}, 列ID=${fieldId}`);
const snapshot = {
recordId,
fieldId,
status: info?.status,
statusCode: info?.statusCode,
modifiedTime: info?.modifiedTime,
logId
};
const prev = failureStore.get(key);
if (!prev || JSON.stringify(prev) !== JSON.stringify(snapshot)) {
failureStore.set(key, snapshot);
hasChange = true;
}
} else if (failureStore.has(key)) {
// logID 为空,移除已存储的失败记录
failureStore.delete(key);
hasChange = true;
}
});
});
if (hasChange) renderList();
}
// ==================== 排除已有图片功能 ====================
// 排除已有图片的任务
function excludeExistingImages() {
const token = prompt('请输入飞书 app token:');
if (!token) return;
isLoading = true;
renderList();
// 获取字段信息
getFields(token).then(fields => {
const fieldNames = fields.map(f => f.field_name);
const fieldMap = new Map(fields.map(f => [f.field_id, f.field_name]));
// 查询记录
searchRecords(token, fieldNames).then(records => {
// 找出已有图片的记录
const existingImages = new Set();
records.forEach(record => {
Object.entries(record.fields).forEach(([fieldName, values]) => {
if (values && values.length > 0 && values[0].file_token) {
// 找到对应的 fieldId
const fieldId = [...fieldMap.entries()].find(([id, name]) => name === fieldName)?.[0];
if (fieldId) {
existingImages.add(`${record.record_id}:${fieldId}`);
}
}
});
});
// 从 failureStore 删除已有图片的项
existingImages.forEach(key => failureStore.delete(key));
isLoading = false;
renderList();
console.log(`已排除 ${existingImages.size} 个已有图片的任务`);
}).catch(err => {
isLoading = false;
renderList();
console.warn('查询记录失败:', err);
alert('查询记录失败,请检查 token 和网络');
});
}).catch(err => {
isLoading = false;
renderList();
console.warn('获取字段信息失败:', err);
alert('获取字段信息失败,请检查 token');
});
}
// 获取字段信息
function getFields(token) {
const url = window.location.href;
const urlObj = new URL(url);
const appToken = urlObj.pathname.split('/').pop();
const tableId = urlObj.searchParams.get('table');
const viewId = urlObj.searchParams.get('view');
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields?page_size=20&view_id=${viewId}`,
headers: { 'Authorization': `Bearer ${token}` },
onload: response => {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
resolve(data.data.items);
} catch (e) {
reject(e);
}
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: reject
});
});
}
// 查询记录
function searchRecords(token, fieldNames) {
const url = window.location.href;
const urlObj = new URL(url);
const appToken = urlObj.pathname.split('/').pop();
const tableId = urlObj.searchParams.get('table');
const viewId = urlObj.searchParams.get('view');
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/search?page_size=500&user_id_type=open_id`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
data: JSON.stringify({
automatic_fields: false,
field_names: fieldNames,
view_id: viewId
}),
onload: response => {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
resolve(data.data.items);
} catch (e) {
reject(e);
}
} else {
reject(new Error(`HTTP ${response.status}: ${response.responseText}`));
}
},
onerror: reject
});
});
}
// ==================== 重试功能 ====================
// 对指定列的所有失败记录发起批量重试
function retryField(fieldId, recordIds) {
// 解析 URL 参数
const url = window.location.href;
const urlObj = new URL(url);
const token = urlObj.pathname.split('/').pop();
const tableId = urlObj.searchParams.get('table');
const viewId = urlObj.searchParams.get('view');
if (!token || !tableId || !viewId) {
console.warn('无法解析 URL 参数: token, tableId 或 viewId');
return;
}
// 从 cookie 获取 CSRF token
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=');
acc[key] = value;
return acc;
}, {});
const csrfToken = cookies['_csrf_token'];
// 构建请求
const retryUrl = `${urlObj.origin}/space/api/bitable/record/update/task/create`;
const body = JSON.stringify({ token, tableId, viewId, fieldId, recordIds });
const headers = {
'Content-Type': 'application/json',
'referer': url,
'x-csrftoken': csrfToken,
...lastRetryHeaders // 合并捕获的 headers
};
// 发起重试请求
GM_xmlhttpRequest({
method: 'POST',
url: retryUrl,
headers,
data: body,
onload: response => {
if (response.status === 200) {
console.log(`重试成功: 列=${fieldId}, 记录=${recordIds.join(',')}`);
recordIds.forEach(recordId => failureStore.delete(`${recordId}:${fieldId}`));
renderList();
} else {
console.warn(`重试失败: 列=${fieldId}, 记录=${recordIds.join(',')}, 状态=${response.status}, 响应=${response.responseText}`);
}
},
onerror: error => console.warn(`重试错误: 列=${fieldId}, 记录=${recordIds.join(',')}`, error)
});
}
// 拦截 fetch 请求
window.fetch = async function(input, init = {}) {
const url = typeof input === 'string' ? input : input.url || '';
const method = (init.method || (typeof input === 'object' ? input.method : '') || 'GET').toUpperCase();
const isCellTag = method === 'POST' && /\/auto_update\/cell_tag/.test(url);
const isRetry = method === 'POST' && /\/record\/update\/task\/create/.test(url);
// 捕获页面重试请求的 headers
if (isRetry) lastRetryHeaders = { ...init.headers };
const response = await originalFetch(input, init);
// 处理 cell_tag 响应
if (isCellTag && response.ok) {
response.clone().json()
.then(payload => handleFailurePayload({ responseBody: payload }))
.catch(err => console.warn('[脚本] 无法解析响应', err));
}
return response;
};
// 拦截 XMLHttpRequest(兼容性)
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._method = method.toUpperCase();
this._url = url;
return originalOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function(body) {
this.addEventListener('load', () => {
if (this._method === 'POST' && /\/auto_update\/cell_tag/.test(this._url) && this.status === 200) {
try {
const payload = JSON.parse(this.responseText);
handleFailurePayload({ responseBody: payload });
} catch (err) {
console.warn('[脚本] 无法解析响应', err);
}
}
});
return originalSend.call(this, body);
};
// ==================== 初始化 ====================
ensurePanel();
renderList();
})();