mik / Feishu Bitable Failure Monitor

// ==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();
})();