EMR 胸痛病历提取

从网页中提取病历字段并生成结构化 JSON,一键复制/下载;适配你提供的 DOM 类名与结构(.name/.sex/.agevalue/.selectage/.lxdh/.pidno/.emr_row/.tz_row 等)

// ==UserScript==
// @name         EMR 胸痛病历提取
// @namespace    emr.extractor.sun
// @version      1.3.2
// @description  从网页中提取病历字段并生成结构化 JSON,一键复制/下载;适配你提供的 DOM 类名与结构(.name/.sex/.agevalue/.selectage/.lxdh/.pidno/.emr_row/.tz_row 等)
// @match        *://*/*
// @run-at       document-end
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  /** ===========================
   *  可调配置:如页面类名有差异,可在此微调
   *  ===========================
   */
  const SEL = {
    name: 'input.name',                   // 姓名
    sex: 'select.sex',                    // 性别
    ageValue: 'input.agevalue',           // 年龄数字
    ageUnit: 'select.selectage',          // 年龄单位(岁/月/天)
    phone: 'input.lxdh',                  // 电话
    idcard: 'input.pidno',                // 居民身份证
    emrBlocks: '.emr_row .content.input_emr', // 主诉/现病史/体检/处理 —— 顺序映射
    tzBlock: '.tz_row',                   // 体征父容器
    outpNo: '#outpNO',                    // 门诊号

    // 体征各项(在 tzBlock 内)
    vsign: {
      tw: '.vsign_tw',            // 体温 ℃
      hx: '.vsign_hx',            // 呼吸 次/分
      mb: '.vsign_mb',            // 脉搏 次/分
      xl: '.vsign_xl',            // 心率 次/分
      gxy: '.vsign_gxy',          // 收缩压 mmHg
      dxy: '.vsign_dxy',          // 舒张压 mmHg
      xt : '.vsign_xt',           // 血糖 mmol/L
      sg : '.vsign_sg',           // 身高 cm
      tz : '.vsign_tz',           // 体重 kg
      bmi: '.vsign_bmi',          // BMI(若页面未计算,脚本会自动计算)
      bloodType: '.vsign_bloodtype', // 血型
      xybhd: '.vsign_xybhd',      // 血氧饱和度 %
      yw : '.vsign_yw',           // 腰围 cm
    }
  };

  // 主诉/现病史/体格检查/处理意见的顺序约定(与页面 emrBlocks 顺序一致)
  const EMR_ORDER = ['chief_complaint', 'present_illness', 'physical_exam', 'treatment_plan'];

  /** ============ 工具函数 ============ */
  const $one = (sel, root = document) => root.querySelector(sel) || null;
  const $all = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const getVal = (el) => {
    if (!el) return '';
    // 优先取 .value;对于 contenteditable 取 innerText;若有 data-value 作为备选
    if (typeof el.value === 'string' && el.value.trim() !== '') return el.value.trim();
    const text = (el.innerText || el.textContent || '').trim();
    if (text) return text;
    if (el.dataset && el.dataset.value) return (el.dataset.value || '').trim();
    if (el.getAttribute) {
      const dv = el.getAttribute('data-oldvalue') || el.getAttribute('data-value') || '';
      if (dv && dv.trim()) return dv.trim();
    }
    return '';
  };

  const getSelectText = (selEl) => {
    if (!selEl) return '';
    const opt = selEl.selectedOptions && selEl.selectedOptions[0];
    if (opt) return (opt.textContent || opt.value || '').trim();
    // 退化:直接返回 value
    return (selEl.value || '').trim();
  };

  const toNumber = (s) => {
    if (typeof s === 'number') return s;
    if (!s) return NaN;
    const m = String(s).match(/-?\d+(\.\d+)?/);
    return m ? parseFloat(m[0]) : NaN;
  };

  const safeText = (s) => (s || '').replace(/\u00A0/g, ' ').trim();

  const computeBMI = (height_cm, weight_kg) => {
    const h = toNumber(height_cm);
    const w = toNumber(weight_kg);
    if (!isFinite(h) || !isFinite(w) || h <= 0) return '';
    const bmi = w / Math.pow(h / 100, 2);
    return bmi ? bmi.toFixed(1) : '';
  };

  const nowISO = () => new Date().toISOString();

  /** ============ 提取主函数 ============ */
  function extractEMR() {
    const nameEl = $one(SEL.name);
    const sexEl = $one(SEL.sex);
    const ageValueEl = $one(SEL.ageValue);
    const ageUnitEl = $one(SEL.ageUnit);
    const phoneEl = $one(SEL.phone);
    const idEl = $one(SEL.idcard);
    const outpNoEl = $one(SEL.outpNo);

    // 获取电话号,直接从 data-* 属性中提取
    let phoneValue = phoneEl ? phoneEl.dataset.value || phoneEl.dataset.oldvalue : '';

    console.log('提取的电话号:', phoneValue); // 打印提取的电话号

    // 获取身份证号,直接从 data-* 属性中提取
    let idCardValue = idEl ? idEl.dataset.value || idEl.dataset.oldvalue : '';

    console.log('提取的身份证号:', idCardValue); // 打印提取的身份证号

    // 获取门诊号
    const outpNoValue = outpNoEl ? getVal(outpNoEl) : '';

    console.log('提取的门诊号:', outpNoValue); // 打印提取的门诊号

    // 基本信息
    const base = {
      outp_no: outpNoValue, // 提取门诊号
      name: getVal(nameEl),
      sex: getSelectText(sexEl) || getVal(sexEl),
      age: (getVal(ageValueEl) ? `${getVal(ageValueEl)}${getSelectText(ageUnitEl) || getVal(ageUnitEl) || ''}` : ''),
      phone: phoneValue, // 直接从 data-* 属性提取电话号
      id_card: idCardValue, // 直接从 data-* 属性提取身份证号
    };

    // 主诉 / 现病史 / 体格检查 / 处理意见(按顺序)
    const emrBlocks = $all(SEL.emrBlocks);  // 更新为选取多个元素
    const emr = {};
    EMR_ORDER.forEach((key, i) => {
      if (key === 'physical_exam') {  // 体格检查(查体)
        const physicalExamElement = document.querySelector('div[section-title="体格检查"] span.content.input_emr');
        emr[key] = safeText(getVal(physicalExamElement));
      } else if (key === 'treatment_plan') {  // 处理意见
        const treatmentPlanElement = document.querySelector('div[section-title="处理意见"] span.content.input_emr');
        emr[key] = safeText(getVal(treatmentPlanElement));
      } else {
        const el = emrBlocks[i];
        emr[key] = safeText(getVal(el));
      }
    });

    // 体征
    const tzRoot = $one(SEL.tzBlock) || document;
    const v = SEL.vsign;

    const tw = getVal($one(v.tw, tzRoot));
    const hx = getVal($one(v.hx, tzRoot));
    const mb = getVal($one(v.mb, tzRoot));
    const xl = getVal($one(v.xl, tzRoot));
    const sys = getVal($one(v.gxy, tzRoot));
    const dia = getVal($one(v.dxy, tzRoot));
    const xt = getVal($one(v.xt, tzRoot));
    const sg = getVal($one(v.sg, tzRoot));
    const tz = getVal($one(v.tz, tzRoot));
    const bmiRaw = getVal($one(v.bmi, tzRoot));
    const bloodType = getVal($one(v.bloodType, tzRoot));
    const xybhd = getVal($one(v.xybhd, tzRoot));
    const yw = getVal($one(v.yw, tzRoot));

    const bmi = bmiRaw || computeBMI(sg, tz);

    const vitals = {
      temperature_c: tw || '',
      respiration_bpm: hx || '',
      pulse_bpm: mb || '',
      heart_rate_bpm: xl || '',
      blood_pressure: (sys || dia) ? `${sys || ''}/${dia || ''} mmHg` : '',
      glucose_mmol_L: xt || '',
      height_cm: sg || '',
      weight_kg: tz || '',
      bmi: bmi || '',
      blood_type: bloodType || '',
      spo2_percent: xybhd || '',
      waist_cm: yw || '',
    };

    const result = {
      meta: {
        extracted_at: nowISO(),
        page_title: document.title,
        url: location.href
      },
      base,
      emr,
      vitals
    };

    return result;
  }

  /** ============ UI:浮窗 + 操作按钮 ============ */
  function createPanel() {
    GM_addStyle(`
      .emr-extractor-panel {
        position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;
        width: 360px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 14px;
        box-shadow: 0 6px 30px rgba(0,0,0,.08); font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,ui-sans-serif;
      }
      .emr-extractor-header {
        display:flex; align-items:center; justify-content:space-between;
        padding: 10px 12px; border-bottom: 1px solid #f1f5f9; cursor: move;
      }
      .emr-extractor-title { font-size: 14px; font-weight: 600; }
      .emr-extractor-body { padding: 12px; }
      .emr-extractor-textarea {
        width:100%; height:160px; font-size:12px; line-height:1.45; padding:8px;
        border:1px solid #e5e7eb; border-radius:10px; resize: vertical; box-sizing: border-box;
        background:#fafafa;
      }
      .emr-extractor-actions { display:flex; gap:8px; margin-top:10px; }
      .emr-btn {
        flex:1; border:1px solid #e5e7eb; background:#0ea5e9; color:#fff; padding:8px 10px;
        border-radius:10px; font-size:12px; cursor:pointer; transition: all .15s ease;
      }
      .emr-btn.secondary { background:#fff; color:#111827; }
      .emr-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0,0,0,.08); }
      .emr-extractor-footer {
        display:flex; justify-content:space-between; align-items:center;
        padding: 8px 12px; border-top:1px solid #f1f5f9; font-size:11px; color:#6b7280;
      }
      .emr-extractor-close { cursor:pointer; padding:2px 6px; border-radius:6px; }
      .emr-extractor-close:hover { background:#f3f4f6; }
    `);

    const panel = document.createElement('div');
    panel.className = 'emr-extractor-panel';
    panel.innerHTML = `
      <div class="emr-extractor-header" id="emrExtractorHeader">
        <div class="emr-extractor-title">病历提取</div>
        <div class="emr-extractor-close" id="emrExtractorClose">✕</div>
      </div>
      <div class="emr-extractor-body">
        <textarea class="emr-extractor-textarea" id="emrExtractorOutput" placeholder="点击“提取数据”后显示 JSON 结果"></textarea>
        <div class="emr-extractor-actions">
          <button class="emr-btn secondary" id="emrExtractorExtract">提取数据</button>
          <button class="emr-btn" id="emrExtractorCopy">复制文本</button>
          <button class="emr-btn secondary" id="emrExtractorDownload">下载JSON</button>
        </div>
      </div>
      <div class="emr-extractor-footer">
        <span>映射顺序:主诉 → 现病史 → 体格检查 → 处理意见</span>
        <span id="emrExtractorStatus"></span>
      </div>
    `;
    document.body.appendChild(panel);

    // 拖动
    (function makeDraggable() {
      const header = panel.querySelector('#emrExtractorHeader');
      let isDown = false, startX=0, startY=0, startLeft=0, startTop=0;
      header.addEventListener('mousedown', (e) => {
        isDown = true;
        startX = e.clientX; startY = e.clientY;
        const rect = panel.getBoundingClientRect();
        startLeft = rect.left; startTop = rect.top;
        e.preventDefault();
      });
      document.addEventListener('mousemove', (e) => {
        if (!isDown) return;
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        panel.style.left = `${startLeft + dx}px`;
        panel.style.top = `${startTop + dy}px`;
        panel.style.right = 'auto';
        panel.style.bottom = 'auto';
      });
      document.addEventListener('mouseup', () => { isDown = false; });
    })();

    // 事件
    const closeBtn = panel.querySelector('#emrExtractorClose');
    const extractBtn = panel.querySelector('#emrExtractorExtract');
    const copyBtn = panel.querySelector('#emrExtractorCopy');
    const downloadBtn = panel.querySelector('#emrExtractorDownload');
    const output = panel.querySelector('#emrExtractorOutput');
    const status = panel.querySelector('#emrExtractorStatus');

    closeBtn.addEventListener('click', () => panel.remove());

    extractBtn.addEventListener('click', () => {
      try {
        const data = extractEMR();
        output.value = JSON.stringify(data, null, 2);
        status.textContent = '✓ 已提取';
        console.info('[EMR Extractor] Extracted:', data);
      } catch (err) {
        console.error('[EMR Extractor] 提取失败:', err);
        status.textContent = '✗ 提取失败';
        alert('提取失败:' + (err && err.message ? err.message : String(err)));
      }
    });

    copyBtn.addEventListener('click', async () => {
      try {
        const data = extractEMR();
        const textToCopy = `
"门诊号": "${data.base.outp_no}",
"姓名": "${data.base.name}",
"性别": "${data.base.sex}",
"年龄": ${data.base.age},
"联系电话": "${data.base.phone}",
"身份证": "${data.base.id_card}",
"现病史": "${data.emr.present_illness}",
"意识": "清醒",
"呼吸": ${data.vitals.respiration_bpm},
"脉搏": ${data.vitals.pulse_bpm},
"心率": ${data.vitals.heart_rate_bpm},
"血压": "${data.vitals.blood_pressure}",
"处理意见": "${data.emr.treatment_plan}"
`;
        // 使用 Clipboard API 复制文本
        if (navigator.clipboard && navigator.clipboard.writeText) {
          await navigator.clipboard.writeText(textToCopy);
          status.textContent = '✓ 已复制';
        } else {
          // 使用 document.execCommand 作为回退方案
          const textarea = document.createElement('textarea');
          textarea.value = textToCopy;
          document.body.appendChild(textarea);
          textarea.select();
          document.execCommand('copy');
          document.body.removeChild(textarea);
          status.textContent = '✓ 已复制';
        }
      } catch (err) {
        console.error('[EMR Extractor] 复制失败:', err);
        status.textContent = '✗ 复制失败';
        alert('复制失败:' + (err && err.message ? err.message : String(err)));
      }
    });

    downloadBtn.addEventListener('click', () => {
      try {
        const val = output.value.trim() || JSON.stringify(extractEMR(), null, 2);
        const blob = new Blob([val], { type: 'application/json;charset=utf-8' });
        const a = document.createElement('a');
        const ts = new Date().toISOString().replace(/[:.]/g, '-');
        a.download = `emr_extract_${ts}.json`;
        a.href = URL.createObjectURL(blob);
        a.click();
        URL.revokeObjectURL(a.href);
        status.textContent = '✓ 已下载';
      } catch (err) {
        console.error('[EMR Extractor] 下载失败:', err);
        status.textContent = '✗ 下载失败';
        alert('下载失败:' + (err && err.message ? err.message : String(err)));
      }
    });
  }

  /** 初始化:避免重复注入 */
  if (!document.querySelector('.emr-extractor-panel')) {
    createPanel();
  }
})();