// ==UserScript== // @name JSON / CSV 阅读器 & 编辑器 (分屏实时预览) // @namespace http://tampermonkey.net/ // @version 1.2.2 // @description 把 .json/.csv/.tsv 拖到任意网页或空白页即可解析成表格;直接打开本地文件时,CSV 没有 JSON 那种内置查看器,会按地址把文件字节读回来再解析。支持列排序、搜索、列显隐、分页、列统计、另存为、Ctrl+S 保存。其它格式一律不拦。 // @author healer007 // @match *://*/* // @match file:///* // @include about:blank // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect * // @run-at document-start // @noframes // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS40Ii8+PHRleHQgeD0iMTIiIHk9IjE2LjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJDb25zb2xhcyxNZW5sbyxtb25vc3BhY2UiIGZvbnQtc2l6ZT0iMTAuNSIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiM1MGZhN2IiPnsgfTwvdGV4dD48L3N2Zz4= // ==/UserScript== /* * 用法 * 1. 浏览器直接打开本地 .json / .csv / .tsv 文件 → 自动解析成分屏界面(左侧表格预览,右侧原文编辑)。 * 2. 编辑右侧原文,左侧实时刷新(解析失败会显示错误并保留原文,不会清空内容)。 * 3. 保存:Ctrl+S 或点「保存」,按当前格式导出;也可以在「另存为」里换成另一种格式导出。 * * 范围边界(和 markdown-reader / word-reader 互斥) * · 脚本会注入到所有网页,但只为了接住拖放。拖进来的文件扩展名不是 .json/.csv/.tsv 时, * 不 preventDefault,浏览器照常打开,另外两个脚本也能接手。 * · 自动铺满页面只发生在 file:// 且扩展名是这三种时。以前只要注入就 buildUi() 清空 body, * .txt/.markdown/.docx 会被盖成一张「请拖入 JSON」的全屏,和另外两个脚本抢页面。 * · http(s) 上的 .json 接口页面不自动接管。 * · 不再用 @exclude-match 互斥:它挡不住 .txt/.markdown,管理器不支持时等于没写, * 而且会让脚本在普通网页上根本不运行,拖进去就没人接。 * * 与浏览器自带查看器的冲突(Chrome / Edge) * · .json 会进内置查看器,原文在
里,但 document_idle 会重建 body 把界面冲掉。 * · .csv / .tsv 通常没有查看器:要么直接下载,要么是一张空白页,里什么都没有。 * 只读就会表现为「JSON 能开,CSV 完全不解析」。 * 对策: * 1. 有就先抓快照(JSON 这条同步路径不能等,查看器马上会把页面换掉); * 2.是空的就按 file:// 地址把字节读回来(UTF-8 失败再试 gb18030); * 3. 挂界面前摘掉 .json-formatter-container,并用 MutationObserver 在根节点被冲掉后挂回去。 * * 数据特性 * · CSV 按 RFC 4180 解析:支持引号包裹、"" 转义、字段内逗号/换行、CRLF、BOM。 * · 分隔符自动嗅探(, ; Tab |),按首行一致性打分选最优。 * · 每列自动推断类型(数字/布尔/文本),排序和统计都按推断后的类型走。 * 超出 JS 安全整数的纯数字(如 17 位 SteamID)保持文本,避免另存为 JSON 时丢精度。 * · JSON 支持对象数组、数组的数组、纯原始值数组;不是数组时退化成可折叠树视图。 * · 大文件只渲染当前页,DOM 节点数与总行数无关。 */ (function () { 'use strict'; /* ============================================================ * 0. 常量与状态 * ============================================================ */ const VERSION = '1.2.2'; const ROOT_ID = 'dr-reader-root'; const EXT_DATA = ['json', 'csv', 'tsv']; const PAGE_SIZES = [50, 100, 200, 500, 1000]; const DEFAULT_PAGE = 100; const MAX_AUTO_PARSE = 64 * 1024 * 1024; // 超过 64MB 就不再自动解析,避免把浏览器拖死 const PREVIEW_DEBOUNCE = 300; const state = { fileName: 'data', format: 'json', // json | csv | tsv original: '', // 载入时的原文,用于「还原」和未保存判断 text: '', // 当前原文(= textarea.value) model: null, // 解析结果 error: '', // 解析错误信息 query: '', sortKey: null, sortDir: 0, // 1 升序 / -1 降序 / 0 不排序 page: 1, pageSize: DEFAULT_PAGE, hidden: {}, // { 列名: true } 隐藏的列 showStats: false, stats: null, debounceTimer: 0 }; const refs = {}; /** * 浏览器自带的 JSON 查看器("美观输出"那个)会在 document_idle 阶段 * 重建整个 body,把油猴脚本注入的界面直接冲掉 —— 表现就是「插件不生效」。 * 所以正文必须一次性抓到自己手里,之后只认这份快照。 */ let capturedSource = null; function captureSource(force) { if (!force && capturedSource !== null) return capturedSource; let text = ''; try { // 只认。刻意不把任意页面的 body.textContent 当数据 —— // 那会把网页自己的脚本文本喂进解析器。 const pre = document.querySelector('body > pre') || document.querySelector('pre'); if (pre) text = pre.textContent || ''; // 本地文件页有时没有,原文直接挂在 body 的文本节点上。 // 已经有子元素时不再收:那是脚本自己的界面,或者是别的网页。 if (!text && location.protocol === 'file:' && document.body && !document.body.firstElementChild) { text = document.body.textContent || ''; } } catch (e) { text = ''; } // 空读不写入缓存。否则 DOMContentLoaded 时还没出来, // 这次空字符串会把后面 load / 重新读取永远挡住。 if (text) capturedSource = text; return text || capturedSource || ''; } /** 摘掉浏览器自带查看器的挂载点,避免它继续往页面上盖东西 */ function killNativeViewer() { try { document.querySelectorAll('.json-formatter-container').forEach(function (el) { el.remove(); }); } catch (e) { /* 忽略 */ } } /* ============================================================ * 1. 小工具 * ============================================================ */ function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function extOf(name) { const i = String(name || '').lastIndexOf('.'); return i < 0 ? '' : String(name).slice(i + 1).toLowerCase(); } function baseName(name) { return String(name || 'data').replace(/\.[^.]+$/, '') || 'data'; } function fmtSize(n) { if (n == null) return ''; const units = ['B', 'KB', 'MB', 'GB']; let i = 0, v = Number(n); while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + ' ' + units[i]; } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(function () { try { a.remove(); } catch (e) { /* 忽略 */ } URL.revokeObjectURL(url); }, 4000); } /** UTF-8 严格解码,失败退 gb18030(中文 Windows 导出的 CSV 常见) */ function decodeText(u8) { try { return new TextDecoder('utf-8', { fatal: true }).decode(u8); } catch (e) { try { return new TextDecoder('gb18030').decode(u8); } catch (e2) { return new TextDecoder('utf-8').decode(u8); } } } function stripBom(s) { return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s; } /* ============================================================ * 2. 解析:CSV / JSON → 统一表格模型 * ============================================================ */ /** * RFC 4180 分隔符解析器。 * 关键点:引号内的分隔符和换行都当普通字符;"" 是转义后的一个引号; * 未加引号字段里出现的引号按字面量处理。 */ function parseDelimited(text, delim) { const rows = []; let row = [], field = '', i = 0, inQuotes = false, touched = false; const n = text.length; while (i < n) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } inQuotes = false; i++; continue; } field += ch; i++; continue; } if (ch === '"' && field === '') { inQuotes = true; touched = true; i++; continue; } if (ch === delim) { row.push(field); field = ''; touched = true; i++; continue; } if (ch === '\r') { i++; continue; } if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; touched = false; i++; continue; } field += ch; i++; } if (field !== '' || touched || row.length) { row.push(field); rows.push(row); } return rows; } /** 嗅探分隔符:取候选里「首行列数最多且后续行最齐」的那个 */ function sniffDelim(text) { const candidates = [',', '\t', ';', '|']; let best = ',', bestScore = -1; for (let k = 0; k < candidates.length; k++) { const sample = parseDelimited(text.slice(0, 200 * 1024), candidates[k]).slice(0, 60); if (sample.length < 2) continue; const cols = sample[0].length; if (cols < 2) continue; let same = 0; for (let i = 1; i < sample.length; i++) if (sample[i].length === cols) same++; const score = same * 1000 + cols; if (score > bestScore) { bestScore = score; best = candidates[k]; } } return best; } const NUM_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; const BOOL_RE = /^(?:true|false|yes|no|是|否|y|n)$/i; /** * 文本能不能当成 JS 数字。 * 纯整数还必须是安全整数:SteamID 这种 17 位数字转 Number 会丢末尾精度, * 排序和「另存为 JSON」都会把好友 ID 改掉。 */ function isPreciseNumber(s) { if (!NUM_RE.test(s)) return false; if (/^-?\d+$/.test(s)) return Number.isSafeInteger(Number(s)); const n = Number(s); return isFinite(n); } /** 按整列取值推断类型:全数字 → number,全布尔 → boolean,否则 string */ function inferType(values) { let num = 0, bool = 0, txt = 0; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v === null || v === undefined || v === '') continue; if (typeof v === 'number') { num++; continue; } if (typeof v === 'boolean') { bool++; continue; } const s = String(v).trim(); if (!s) continue; if (isPreciseNumber(s)) num++; else if (BOOL_RE.test(s)) bool++; else txt++; } if (num && !txt && !bool) return 'number'; if (bool && !txt && !num) return 'boolean'; return 'string'; } /** 按列类型把单元格转成可排序/可统计的值;空值返回 undefined */ function castVal(v, type) { if (v === null || v === undefined || v === '') return undefined; if (type === 'number') { if (typeof v === 'number') return isFinite(v) ? v : undefined; const s = String(v).trim(); if (!isPreciseNumber(s)) return undefined; return Number(s); } if (type === 'boolean') { const s = String(v).trim(); if (!s) return undefined; return /^(?:true|yes|y|是|1)$/i.test(s); } if (typeof v === 'object') return JSON.stringify(v); return String(v); } /** 单元格显示文本:嵌套结构压缩成摘要,完整内容放 title 里 */ function cellText(v) { if (v === null) return 'null'; if (v === undefined) return ''; if (Array.isArray(v)) { if (!v.length) return '[]'; const head = v.slice(0, 3).map(function (x) { return typeof x === 'object' && x !== null ? JSON.stringify(x) : String(x); }).join(', '); return '[' + v.length + '] ' + head + (v.length > 3 ? ' …' : ''); } if (typeof v === 'object') { const keys = Object.keys(v); if (!keys.length) return '{}'; return '{' + keys.length + '} ' + keys.slice(0, 3).join(', ') + (keys.length > 3 ? ' …' : ''); } return String(v); } function cellTitle(v) { if (v !== null && v !== undefined && typeof v === 'object') { try { return JSON.stringify(v); } catch (e) { return ''; } } return ''; } /** JSON 值 → 表格模型;非数组返回 kind:'tree' */ function modelFromJson(value) { if (!Array.isArray(value)) { return { kind: 'tree', jsonValue: value, headers: [], rows: [] }; } // 空数组:给出明确提示而不是空白表格 if (!value.length) { return { kind: 'json', jsonValue: value, headers: [], rows: [], types: {} }; } // 数组的数组 / 原始值数组 → 统一转成「每列一个 header」 const allObjects = value.every(function (r) { return r !== null && typeof r === 'object' && !Array.isArray(r); }); const width = value.reduce(function (m, r) { return Math.max(m, Array.isArray(r) ? r.length : 1); }, 0); let headers, rows, types; if (allObjects) { // 列顺序 = 首次出现顺序(不会因为后面某行多了个 key 就乱序) headers = []; const seen = {}; for (let i = 0; i < value.length && headers.length < 500; i++) { const r = value[i]; for (const k of Object.keys(r)) { if (!Object.prototype.hasOwnProperty.call(seen, k)) { seen[k] = 1; headers.push(k); } } } rows = value.map(function (r) { return headers.map(function (h) { return r[h]; }); }); } else if (value.every(function (r) { return Array.isArray(r); })) { headers = []; for (let i = 0; i < width; i++) headers.push('列 ' + (i + 1)); rows = value.map(function (r) { const out = new Array(width); for (let i = 0; i < width; i++) out[i] = r[i]; return out; }); } else { headers = ['值']; rows = value.map(function (r) { return [r]; }); } types = {}; for (let c = 0; c < headers.length; c++) { const col = new Array(rows.length); for (let r = 0; r < rows.length; r++) col[r] = rows[r][c]; types[headers[c]] = inferType(col); } return { kind: 'json', jsonValue: value, headers: headers, rows: rows, types: types }; } /** 文本 → 解析模型;格式靠扩展名 + 内容嗅探 */ function parseData(text, fileName) { const raw = stripBom(String(text == null ? '' : text)); const ext = extOf(fileName) || state.format; const looksJson = /^\s*[[{]/.test(raw); let format; if (ext === 'json') format = 'json'; else if (ext === 'csv' || ext === 'tsv') format = 'csv'; else format = looksJson ? 'json' : 'csv'; state.format = ext === 'tsv' ? 'tsv' : (format === 'json' ? 'json' : 'csv'); if (format === 'json') { let value; try { value = JSON.parse(raw); } catch (e) { throw new Error('JSON 解析失败:' + (e && e.message ? e.message : e)); } const m = modelFromJson(value); m.format = 'json'; if (ext === 'tsv') m.delim = '\t'; return m; } const delim = ext === 'tsv' ? '\t' : sniffDelim(raw); const table = parseDelimited(raw, delim).filter(function (r) { return r.length > 1 || (r.length === 1 && r[0] !== ''); }); if (!table.length) throw new Error('没有解析到任何数据行'); const headers = table[0].map(function (h, i) { return String(h).trim() || '列 ' + (i + 1); }); const rows = table.slice(1).map(function (r) { const out = new Array(headers.length); for (let i = 0; i < headers.length; i++) out[i] = i < r.length ? r[i] : ''; return out; }); const types = {}; for (let c = 0; c < headers.length; c++) { const col = new Array(rows.length); for (let r = 0; r < rows.length; r++) col[r] = rows[r][c]; types[headers[c]] = inferType(col); } return { kind: 'csv', format: state.format, delim: delim, headers: headers, rows: rows, types: types, jsonValue: null }; } /* ---------------- 序列化 ---------------- */ function csvEscape(v) { let s; if (v === null || v === undefined) s = ''; else if (typeof v === 'object') { try { s = JSON.stringify(v); } catch (e) { s = String(v); } } else s = String(v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function toCsv(model, delim) { const d = delim || (model && model.delim) || ','; const lines = []; lines.push((model.headers || []).map(csvEscape).join(d)); (model.rows || []).forEach(function (r) { lines.push(r.map(csvEscape).join(d)); }); // 带 BOM,Excel / WPS 双击才不会乱码(和样例文件保持一致) return '\ufeff' + lines.join('\r\n') + '\r\n'; } /** CSV 表格模型 → JSON 值(按列类型回填数字/布尔) */ function tableToJson(model) { const headers = model.headers || []; return (model.rows || []).map(function (r) { const o = {}; headers.forEach(function (h, i) { const type = (model.types && model.types[h]) || 'string'; o[h] = castVal(r[i], type); }); return o; }); } /* ============================================================ * 3. 样式 * ============================================================ */ const CSS = [ 'html,body{height:100%;}', 'body{margin:0;padding:0;overflow:hidden;background:#1e1f29;color:#f8f8f2;', 'font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Helvetica,Arial,sans-serif;}', '.dr-root{display:flex;flex-direction:column;height:100vh;width:100vw;}', /* 顶部工具条 */ '.dr-topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 12px;flex:0 0 auto;', 'background:#282a36;border-bottom:1px solid #44475a;}', '.dr-badge{display:inline-flex;align-items:center;justify-content:center;height:21px;padding:0 7px;border-radius:5px;', 'font-size:10.5px;font-weight:700;letter-spacing:.5px;background:rgba(80,250,123,.14);border:1px solid rgba(80,250,123,.45);color:#50fa7b;}', '.dr-badge-csv{background:rgba(255,184,108,.14);border-color:rgba(255,184,108,.45);color:#ffb86c;}', '.dr-name{font-weight:600;font-size:13px;max-width:26vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '.dr-meta{color:#6272a4;font-size:12px;white-space:nowrap;}', '.dr-dirty{color:#f1fa8c;font-size:12px;white-space:nowrap;}', '.dr-grow{flex:1 1 auto;}', '.dr-btn{appearance:none;border:1px solid #44475a;background:#313244;color:#f8f8f2;border-radius:6px;', 'padding:4px 10px;font-size:12px;line-height:1.5;cursor:pointer;white-space:nowrap;transition:.14s;}', '.dr-btn:hover{border-color:#50fa7b;color:#50fa7b;}', '.dr-btn:active{transform:translateY(1px);}', '.dr-btn.primary{background:rgba(80,250,123,.16);border-color:rgba(80,250,123,.55);color:#50fa7b;font-weight:600;}', '.dr-btn.on{background:rgba(80,250,123,.24);border-color:#50fa7b;color:#50fa7b;}', '.dr-sel{appearance:none;background:#313244;color:#f8f8f2;border:1px solid #44475a;border-radius:6px;', 'padding:4px 6px;font-size:12px;cursor:pointer;}', '.dr-search{background:#313244;border:1px solid #44475a;border-radius:6px;color:#f8f8f2;', 'padding:4px 9px;font-size:12px;width:190px;outline:none;}', '.dr-search:focus{border-color:#50fa7b;}', '.dr-search::placeholder{color:#6272a4;}', /* 列显隐面板 */ '.dr-colpanel{position:absolute;z-index:30;top:34px;right:12px;min-width:190px;max-height:60vh;overflow:auto;', 'padding:7px 9px;border-radius:9px;background:#313244;border:1px solid #44475a;box-shadow:0 14px 40px rgba(0,0,0,.55);}', '.dr-colpanel h4{margin:0 0 6px;font-size:11px;color:#6272a4;font-weight:600;letter-spacing:.4px;}', '.dr-colpanel label{display:flex;align-items:center;gap:6px;font-size:12px;padding:3px 2px;cursor:pointer;border-radius:4px;}', '.dr-colpanel label:hover{background:#44475a;}', '.dr-colpanel input{accent-color:#50fa7b;}', '.dr-colpanel .dr-tp{color:#6272a4;font-size:10.5px;margin-left:auto;padding-left:10px;}', /* 统计面板 */ '.dr-statspanel{flex:0 0 auto;max-height:30vh;overflow:auto;background:#21222c;border-bottom:1px solid #44475a;padding:8px 12px;}', '.dr-statspanel table{border-collapse:collapse;width:100%;font-size:12px;}', '.dr-statspanel th,.dr-statspanel td{border:1px solid #44475a;padding:3px 8px;text-align:left;white-space:nowrap;}', '.dr-statspanel th{background:#313244;color:#bd93f9;font-weight:600;}', '.dr-statspanel td{color:#cfd3e0;}', /* 分屏 */ '.dr-split{flex:1 1 auto;display:flex;min-height:0;}', '.dr-pane{overflow:auto;box-sizing:border-box;height:100%;}', '.dr-pane-left{flex:1 1 0;min-width:0;background:#21222c;}', '.dr-pane-right{flex:1 1 0;min-width:0;background:#282a36;border-left:0;position:relative;}', '.dr-editor{width:100%;height:100%;background:transparent;color:#f8f8f2;border:none;resize:none;outline:none;padding:12px 14px;', 'box-sizing:border-box;white-space:pre;overflow-wrap:normal;tab-size:2;', 'font:12.5px/1.6 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;}', '.dr-splitter{flex:0 0 6px;background:#44475a;cursor:col-resize;position:relative;z-index:5;transition:background .15s;}', '.dr-splitter:hover,.dr-splitter.dragging{background:#50fa7b;}', '.dr-splitter::after{content:"⋮";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888;font-size:16px;line-height:1;}', /* 表格 */ '.dr-tablewrap{padding:0 0 40px;}', '.dr-table{border-collapse:separate;border-spacing:0;font-size:12.5px;width:max-content;min-width:100%;}', '.dr-table th,.dr-table td{border-right:1px solid #44475a;border-bottom:1px solid #44475a;padding:5px 10px;', 'white-space:nowrap;max-width:420px;overflow:hidden;text-overflow:ellipsis;vertical-align:top;}', '.dr-table thead th{position:sticky;top:0;z-index:2;background:#313244;color:#bd93f9;cursor:pointer;user-select:none;', 'border-bottom:1px solid #6272a4;font-weight:600;}', '.dr-table thead th:hover{color:#50fa7b;}', '.dr-table thead th .dr-arrow{color:#50fa7b;font-size:10px;margin-left:4px;}', '.dr-table thead th .dr-tp{color:#6272a4;font-size:10px;margin-left:5px;font-weight:400;}', '.dr-table tbody tr:nth-child(even){background:rgba(255,255,255,.022);}', '.dr-table tbody tr:hover{background:rgba(80,250,123,.07);}', '.dr-table td.dr-num{text-align:right;font-family:"SFMono-Regular",Consolas,Menlo,monospace;color:#bd93f9;}', '.dr-table td.dr-bool{text-align:center;color:#ffb86c;}', '.dr-table td.dr-null{color:#6272a4;font-style:italic;}', '.dr-table td.dr-deep{color:#8be9fd;}', '.dr-table td.dr-rownum{color:#6272a4;background:rgba(255,255,255,.03);text-align:right;position:sticky;left:0;z-index:1;}', /* 分页条 */ '.dr-foot{flex:0 0 auto;display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:5px 12px;', 'background:#282a36;border-top:1px solid #44475a;color:#6272a4;font-size:12px;}', '.dr-foot b{color:#f8f8f2;font-weight:600;}', '.dr-foot .dr-btn{padding:2px 8px;font-size:11.5px;}', /* 空态 / 错误 */ '.dr-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:9px;', 'color:#6272a4;text-align:center;padding:40px;}', '.dr-empty .dr-big{font-size:40px;line-height:1;}', '.dr-err{margin:14px;padding:12px 14px;border-radius:9px;background:rgba(255,85,85,.1);', 'border:1px solid rgba(255,85,85,.45);color:#ffb3b3;font-size:12.5px;}', '.dr-err b{display:block;margin-bottom:4px;color:#ff6b6b;font-size:13px;}', '.dr-err code{background:rgba(0,0,0,.3);border-radius:4px;padding:1px 5px;color:#ffb3b3;}', /* 树视图 */ '.dr-treewrap{padding:12px 14px;font-family:"SFMono-Regular",Consolas,Menlo,monospace;font-size:12.5px;line-height:1.6;}', '.dr-tree details{margin-left:14px;border-left:1px dotted #44475a;padding-left:10px;}', '.dr-tree summary{cursor:pointer;list-style:none;outline:none;}', '.dr-tree summary::-webkit-details-marker{display:none;}', '.dr-tree summary::before{content:"▸";display:inline-block;width:12px;color:#6272a4;transition:transform .12s;}', '.dr-tree details[open]>summary::before{transform:rotate(90deg);}', '.dr-tree .dr-k{color:#bd93f9;}', '.dr-tree .dr-s{color:#50fa7b;}', '.dr-tree .dr-n{color:#ffb86c;}', '.dr-tree .dr-b{color:#ff79c6;}', '.dr-tree .dr-null{color:#6272a4;font-style:italic;}', '.dr-tree .dr-cnt{color:#6272a4;font-size:11px;margin-left:6px;}', /* 拖放遮罩 */ '#dr-drop-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;display:flex;align-items:center;', 'justify-content:center;background:rgba(40,42,54,.95);opacity:0;pointer-events:none;transition:opacity .2s;}', '#dr-drop-overlay.show{opacity:1;pointer-events:auto;}', '#dr-drop-overlay .dr-box{text-align:center;border:3px dashed #50fa7b;padding:52px 74px;border-radius:22px;background:rgba(30,31,41,.8);}', '#dr-drop-overlay .dr-ico{font-size:64px;margin-bottom:16px;}', '#dr-drop-overlay .dr-tx{font-size:24px;font-weight:600;margin-bottom:8px;}', '#dr-drop-overlay .dr-hi{font-size:14px;color:#6272a4;}', /* 滚动条 */ '.dr-pane::-webkit-scrollbar,.dr-statspanel::-webkit-scrollbar,.dr-colpanel::-webkit-scrollbar{width:10px;height:10px;}', '.dr-pane::-webkit-scrollbar-track,.dr-statspanel::-webkit-scrollbar-track{background:#1e1f29;}', '.dr-pane::-webkit-scrollbar-thumb,.dr-statspanel::-webkit-scrollbar-thumb,.dr-colpanel::-webkit-scrollbar-thumb{', 'background:#44475a;border-radius:5px;border:2px solid #1e1f29;}', '.dr-pane::-webkit-scrollbar-thumb:hover{background:#6272a4;}', '#dr-drop-overlay,#dr-drop-overlay *{box-sizing:border-box;}' ].join('\n'); let stylesInjected = false; function injectStyles() { if (stylesInjected) return; stylesInjected = true; try { if (typeof GM_addStyle === 'function') { GM_addStyle(CSS); return; } } catch (e) { /* 走兜底 */ } const st = document.createElement('style'); st.textContent = CSS; (document.head || document.documentElement).appendChild(st); } /** 声明当前页面归本脚本。markdown-reader 接管时会改成 markdown,守卫看到就停手。 */ function claimReader() { try { document.documentElement.dataset.fileReader = 'data'; } catch (e) { /* 忽略 */ } } function readerStillOurs() { try { return document.documentElement.dataset.fileReader === 'data'; } catch (e) { return false; } } /* ============================================================ * 4. 界面 * ============================================================ */ function buildUi() { const existing = document.getElementById(ROOT_ID); if (existing && existing.parentNode === document.body) return; // 已经好好挂着,别重复建 if (existing) existing.remove(); // 被塞到奇怪的位置,拆了重来 // 关键:先把原文抓到手,再动 DOM。 // 原来这里是先 innerHTML='' 把 body 清了,之后再去读,读到的永远是空的。 // 只在「这个页面本身就是数据文件」时抓。出现在别人的网页上时,抓来的是页面文案不是文件。 if (autoTarget()) captureSource(); claimReader(); injectStyles(); document.body.innerHTML = ''; document.documentElement.style.height = '100%'; const root = document.createElement('div'); root.className = 'dr-root'; root.id = ROOT_ID; // ---- 顶部工具条 ---- const top = document.createElement('div'); top.className = 'dr-topbar'; top.innerHTML = [ 'JSON', 'data', '', '', '', '', '', '', '', '', '', '', '' ].join(''); // ---- 列显隐面板 ---- const colPanel = document.createElement('div'); colPanel.className = 'dr-colpanel'; colPanel.hidden = true; // ---- 统计面板 ---- const statPanel = document.createElement('div'); statPanel.className = 'dr-statspanel'; statPanel.hidden = true; // ---- 分屏 ---- const split = document.createElement('div'); split.className = 'dr-split'; const left = document.createElement('div'); left.className = 'dr-pane dr-pane-left'; left.setAttribute('data-ref', 'left'); const splitter = document.createElement('div'); splitter.className = 'dr-splitter'; const right = document.createElement('div'); right.className = 'dr-pane dr-pane-right'; const editor = document.createElement('textarea'); editor.className = 'dr-editor'; editor.spellcheck = false; editor.setAttribute('data-ref', 'editor'); editor.setAttribute('wrap', 'off'); right.appendChild(editor); split.appendChild(left); split.appendChild(splitter); split.appendChild(right); // ---- 分页条 ---- const foot = document.createElement('div'); foot.className = 'dr-foot'; foot.setAttribute('data-ref', 'foot'); root.appendChild(top); root.appendChild(statPanel); root.appendChild(split); root.appendChild(foot); document.body.appendChild(root); // ---- 拖放遮罩 ---- const overlay = document.createElement('div'); overlay.id = 'dr-drop-overlay'; overlay.innerHTML = [ '', '' ].join(''); document.body.appendChild(overlay); root.querySelectorAll('[data-ref]').forEach(function (el) { refs[el.getAttribute('data-ref')] = el; }); refs.root = root; refs.top = top; refs.colPanel = colPanel; refs.statPanel = statPanel; refs.left = left; refs.right = right; refs.editor = editor; refs.splitter = splitter; refs.foot = foot; refs.overlay = overlay; document.body.appendChild(colPanel); bindUi(); } /* ============================================================ * 5. 渲染 * ============================================================ */ function visibleHeaders() { const m = state.model; if (!m || m.kind !== 'json' && m.kind !== 'csv') return []; return m.headers.filter(function (h) { return !state.hidden[h]; }); } function compareVals(a, b, type) { if (a === undefined && b === undefined) return 0; if (a === undefined) return 1; // 空值恒排最后 if (b === undefined) return -1; if (type === 'number') return a - b; if (type === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } /** 应用搜索 + 排序,返回 { indexes, rows } */ function buildView() { const m = state.model; const headers = visibleHeaders(); const idx = headers.map(function (h) { return m.headers.indexOf(h); }); const q = state.query.trim().toLowerCase(); const keep = []; for (let r = 0; r < m.rows.length; r++) { if (q) { let hit = false; for (let c = 0; c < idx.length; c++) { if (cellText(m.rows[r][idx[c]]).toLowerCase().indexOf(q) >= 0) { hit = true; break; } } if (!hit) continue; } keep.push(r); } if (state.sortKey && state.sortDir) { const ci = m.headers.indexOf(state.sortKey); if (ci >= 0) { const type = (m.types && m.types[state.sortKey]) || 'string'; const dir = state.sortDir; keep.sort(function (x, y) { const d = compareVals(castVal(m.rows[x][ci], type), castVal(m.rows[y][ci], type), type); return d !== 0 ? d * dir : x - y; // 同值时保持原始顺序 }); } } return { indexes: idx, rows: keep }; } /* 一次渲染会用到筛选结果好几遍(表头 / 表格 / 分页 / 统计), 5 万行时每遍都是全表扫描,所以按状态签名缓存一份。 */ let viewCache = { key: null, val: null }; let modelSeq = 0; function viewKey() { const m = state.model; if (!m) return 'none'; let hid = ''; for (const k of Object.keys(state.hidden)) if (state.hidden[k]) hid += k + '\u0001'; return [m.__id, state.query, state.sortKey, state.sortDir, hid].join('\u0000'); } function getView() { const key = viewKey(); if (viewCache.key === key) return viewCache.val; const val = buildView(); viewCache = { key: key, val: val }; return val; } function columnStats() { const m = state.model; const view = getView(); const hasNum = m.headers.some(function (h) { return (m.types && m.types[h]) === 'number'; }); const hasBool = m.headers.some(function (h) { return (m.types && m.types[h]) === 'boolean'; }); const canDistinct = view.rows.length <= 20000; const out = []; m.headers.forEach(function (h, ci) { const type = (m.types && m.types[h]) || 'string'; let nonEmpty = 0, sum = 0, mn = Infinity, mx = -Infinity, tCount = 0, fCount = 0; const seen = (type === 'string' && canDistinct) ? Object.create(null) : null; for (let i = 0; i < view.rows.length; i++) { const v = castVal(m.rows[view.rows[i]][ci], type); if (v === undefined) continue; nonEmpty++; if (type === 'number') { sum += v; if (v < mn) mn = v; if (v > mx) mx = v; } else if (type === 'boolean') { if (v) tCount++; else fCount++; } else if (seen) { seen[String(v)] = 1; } } out.push({ name: h, type: type, nonEmpty: nonEmpty, viewRows: view.rows.length, distinct: seen ? Object.keys(seen).length : null, sum: sum, min: type === 'number' ? (nonEmpty ? mn : null) : null, max: type === 'number' ? (nonEmpty ? mx : null) : null, tCount: tCount, fCount: fCount }); }); return { stats: out, viewRows: view.rows.length, hasNum: hasNum, hasBool: hasBool, canDistinct: canDistinct }; } function fmtNum(n) { if (n === null || n === undefined || !isFinite(n)) return '—'; if (Number.isInteger(n)) return n.toLocaleString('en-US'); return Number(n.toFixed(4)).toLocaleString('en-US'); } function typeLabel(t) { return t === 'number' ? '数字' : t === 'boolean' ? '布尔' : '文本'; } function renderTable() { const m = state.model; const left = refs.left; if (state.error) { left.innerHTML = [ '{ }', '将 JSON / CSV 文件拖放到此处', '支持 .json .csv .tsv', '解析失败', escapeHtml(state.error), '' ].join(''); return; } if (!m) { left.innerHTML = [ '左侧预览已暂停,右侧原文可继续编辑;改好后点「重新解析」。', '', '' ].join(''); return; } if (m.kind === 'tree') { left.innerHTML = '{ }', '拖入 .json / .csv / .tsv 文件,或点右上角「列」旁的编辑区直接粘贴内容', '' + renderTree(m.jsonValue, 0) + ''; return; } if (!m.rows.length) { left.innerHTML = [ '', '' ].join(''); return; } const headers = visibleHeaders(); const view = getView(); const rows = view.rows; const total = m.rows.length; const pages = Math.max(1, Math.ceil(rows.length / state.pageSize)); if (state.page > pages) state.page = pages; const from = (state.page - 1) * state.pageSize; const pageRows = rows.slice(from, from + state.pageSize); const html = ['∅', '' + escapeHtml(state.format === 'json' ? '这个 JSON 是个空数组 []' : 'CSV 只有表头,没有数据行') + '', ''); left.innerHTML = html.join(''); } /** 非数组 JSON 的可折叠树 */ function renderTree(v, depth) { const open = depth < 2 ? ' open' : ''; if (v === null) return 'null'; if (Array.isArray(v)) { if (!v.length) return '[]'; return '
', ' '); for (let i = 0; i < pageRows.length; i++) { const srcRow = m.rows[pageRows[i]]; html.push('# ']; headers.forEach(function (h) { const type = (m.types && m.types[h]) || 'string'; const arrow = state.sortKey === h && state.sortDir ? '' + (state.sortDir > 0 ? '▲' : '▼') + '' : ''; html.push('' + escapeHtml(h) + arrow + '' + typeLabel(type) + ' '); }); html.push(''); } html.push(' ' + (pageRows[i] + 1) + ' '); for (let c = 0; c < headers.length; c++) { const ci = m.headers.indexOf(headers[c]); const raw = srcRow[ci]; const type = (m.types && m.types[headers[c]]) || 'string'; const text = cellText(raw); let cls = ''; if (raw === null) cls = 'dr-null'; else if (raw !== undefined && typeof raw === 'object') cls = 'dr-deep'; else if (type === 'number') cls = 'dr-num'; else if (type === 'boolean') cls = 'dr-bool'; const title = cellTitle(raw); html.push('' + escapeHtml(text) + ' '); } html.push(''; } if (typeof v === 'object') { const keys = Object.keys(v); if (!keys.length) return '{}'; return 'Array' + '' + v.length + ' 项
' + v.map(function (x, i) { return '' + renderTree(x, depth + 1) + ''; }).join('') + ''; } if (typeof v === 'string') return '"' + escapeHtml(v) + '"'; if (typeof v === 'number') return '' + escapeHtml(String(v)) + ''; if (typeof v === 'boolean') return '' + escapeHtml(String(v)) + ''; return escapeHtml(String(v)); } function renderHead() { const m = state.model; const isCsv = state.format !== 'json'; refs.badge.textContent = isCsv ? state.format.toUpperCase() : 'JSON'; refs.badge.className = 'dr-badge' + (isCsv ? ' dr-badge-csv' : ''); refs.name.textContent = state.fileName; refs.dirty.textContent = state.text === state.original ? '' : '● 未保存'; if (state.error) { refs.meta.textContent = state.text.length.toLocaleString('en-US') + ' 字符 · 解析失败'; } else if (!m) { refs.meta.textContent = ''; } else if (m.kind === 'tree') { refs.meta.textContent = '顶层不是数组,已用树视图展示'; } else if (!m.rows.length) { refs.meta.textContent = (m.headers || []).length + ' 列 · 0 行'; } else { const filtered = state.query.trim() ? getView().rows.length : m.rows.length; refs.meta.textContent = (m.headers || []).length + ' 列 · ' + m.rows.length.toLocaleString('en-US') + ' 行' + (filtered !== m.rows.length ? '(筛出 ' + filtered.toLocaleString('en-US') + ')' : ''); } } function renderFoot() { const m = state.model; if (state.error || !m || m.kind === 'tree') { refs.foot.innerHTML = ''; return; } const rows = m.rows.length; if (!rows) { refs.foot.innerHTML = ''; return; } const view = getView(); const filtered = view.rows.length; const pages = Math.max(1, Math.ceil(filtered / state.pageSize)); const from = (state.page - 1) * state.pageSize; const shownFrom = filtered ? from + 1 : 0; const shownTo = Math.min(filtered, from + state.pageSize); refs.foot.innerHTML = [ '显示 ' + shownFrom.toLocaleString('en-US') + '–' + shownTo.toLocaleString('en-US') + ' / 筛选后 ' + filtered.toLocaleString('en-US') + ' / 总计 ' + rows.toLocaleString('en-US') + ' 行', '', '每页 ', '第 ' + state.page + ' / ' + pages + ' 页', '', '', '', '' ].join(''); } function renderStats() { if (!state.showStats) { refs.statPanel.hidden = true; return; } const m = state.model; if (state.error || !m || m.kind === 'tree') { refs.statPanel.hidden = false; refs.statPanel.innerHTML = 'Object' + '' + keys.length + ' 键
' + keys.map(function (k) { return '' + escapeHtml(k) + ': ' + renderTree(v[k], depth + 1) + ''; }).join('') + '' + (state.error ? '解析失败,无法统计。' : '树视图不提供列统计。') + ''; return; } refs.statPanel.hidden = false; const r = state.stats || (state.stats = columnStats()); const head = ['列 ', '类型 ', '非空 ', '缺失 ']; if (r.hasNum) head.push('最小 ', '最大 ', '平均 ', '合计 '); if (r.hasBool) head.push('true ', 'false '); if (r.canDistinct) head.push('不同值 '); const body = r.stats.map(function (s) { const td = ['' + escapeHtml(s.name) + ' ', '' + typeLabel(s.type) + ' ', '' + s.nonEmpty.toLocaleString('en-US') + ' ', '' + (s.viewRows - s.nonEmpty).toLocaleString('en-US') + ' ']; if (r.hasNum) { td.push('' + fmtNum(s.min) + ' ', '' + fmtNum(s.max) + ' ', '' + fmtNum(s.nonEmpty ? s.sum / s.nonEmpty : null) + ' ', '' + fmtNum(s.sum) + ' '); } if (r.hasBool) { td.push('' + s.tCount.toLocaleString('en-US') + ' ', '' + s.fCount.toLocaleString('en-US') + ' '); } if (r.canDistinct) td.push('' + (s.distinct === null ? '—' : s.distinct.toLocaleString('en-US')) + ' '); return '' + td.join('') + ' '; }).join(''); refs.statPanel.innerHTML = '
里,JSON 查看器也是建在它旁边) */
function readPageText() {
return captureSource();
}
function registerMenus() {
if (typeof GM_registerMenuCommand !== 'function') return;
try {
GM_registerMenuCommand('📂 打开 JSON / CSV 文件…', pickFiles);
GM_registerMenuCommand('💾 保存当前内容', function () { save(undefined); });
GM_registerMenuCommand('🔄 重新解析', function () { reparse(false); });
} catch (e) { /* 忽略 */ }
}
/** 对外暴露一点口子,方便在控制台/自动化里驱动(和 word-reader.js 的做法一致) */
function exposeApi() {
const api = {
version: VERSION,
loadText: loadText,
open: pickFiles,
save: save,
reparse: reparse,
state: state
};
try { window.DataReader = api; } catch (e) { /* 忽略 */ }
try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).DataReader = api; } catch (e) { /* 忽略 */ }
}
/**
* 浏览器自带 JSON 查看器会重建 body 把界面冲掉。
* 这里盯着 document,一旦发现自己的根节点没了就按快照重新挂回去。
* 用 rAF 合并:一帧内几十次 DOM 变动只做一次检查,不会卡。
*/
function guardNativeViewer() {
let scheduled = false;
let observer = null;
const check = function () {
scheduled = false;
// markdown / word 已经声明接管时,不要把界面盖回去。
if (!readerStillOurs()) {
try { if (observer) observer.disconnect(); } catch (e) { /* 忽略 */ }
return;
}
const root = document.getElementById(ROOT_ID);
// 根节点必须直接挂在 body 下才算「界面还在」。
// 被浏览器连同 一起重新插到别处,也当失效处理,重新挂一次。
if (!root || root.parentNode !== document.body) remount();
else killNativeViewer();
};
const schedule = function () {
if (scheduled) return;
scheduled = true;
const raf = window.requestAnimationFrame || function (fn) { setTimeout(fn, 16); };
raf(check);
};
try {
observer = new MutationObserver(schedule);
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
} catch (e) { /* 忽略 */ }
}
/** 界面被冲掉后重建:用已经抓在手里的正文快照恢复 */
function remount() {
const fileName = state.fileName;
buildUi();
killNativeViewer();
// 已经载入过的内容优先(用户可能改过)。页面原文快照只用于「还没 loadText 就被浏览器冲掉」。
if (state.text) {
loadText(state.text, fileName);
} else if (capturedSource && capturedSource.trim() && fileName && fileName !== 'data') {
loadText(capturedSource, fileName);
} else {
renderAll();
showOverlay(true);
}
}
/** 取不到原文时也照样给出界面(错误面板本身就是指引),绝不留一片空白让人以为脚本没装 */
function showEmptyState(auto, msg) {
state.fileName = auto;
state.format = extOf(auto) === 'json' ? 'json' : extOf(auto);
state.error = msg;
state.text = '';
state.model = null;
refs.editor.value = '';
renderAll();
}
function startAutoContent(text, auto) {
if (text.length > MAX_AUTO_PARSE) {
showEmptyState(auto, '文件超过 64 MB,已跳过自动解析。可以把内容粘贴到右侧再点「重新解析」。');
showOverlay(true);
return;
}
loadText(text, auto);
}
let viewerGuarding = false;
function ensureUi() {
if (!document.body) return;
buildUi();
if (viewerGuarding) return;
viewerGuarding = true;
guardNativeViewer();
}
function bufferToText(buf) {
if (buf == null || buf === '') return '';
if (typeof buf === 'string') return buf;
try {
if (buf instanceof ArrayBuffer) return decodeText(new Uint8Array(buf));
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(buf)) {
return decodeText(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
}
} catch (e) { /* 忽略 */ }
return '';
}
/** 页面自己的 XHR。油猴沙箱里的 XHR 经常读不到 file://,必须借页面的。 */
function pageXhr(url, done, binary) {
const XHR = (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.XMLHttpRequest)
? unsafeWindow.XMLHttpRequest
: XMLHttpRequest;
let xhr;
try { xhr = new XHR(); } catch (e) { done(''); return; }
try { xhr.open('GET', url, true); } catch (e) { done(''); return; }
if (binary) { try { xhr.responseType = 'arraybuffer'; } catch (e) { /* 退回文本 */ } }
xhr.onload = function () {
if (xhr.status && xhr.status !== 200 && xhr.status !== 0) { done(''); return; }
if (binary) {
const text = bufferToText(xhr.response);
if (text && text.trim()) { done(text); return; }
pageXhr(url, done, false);
return;
}
done(xhr.responseText || '');
};
xhr.onerror = function () {
if (binary) pageXhr(url, done, false);
else done('');
};
try { xhr.send(); } catch (e) {
if (binary) pageXhr(url, done, false);
else done('');
}
}
/**
* 按文件地址把字节读回来。
* Chrome / Edge 打开 .csv 时经常不生成 (JSON 才有内置查看器),
* 只盯着 DOM 就会什么都不解析。
*/
function readFileUrl(url, done) {
let settled = false;
const finish = function (text) {
if (settled) return;
settled = true;
try { done(text || ''); } catch (e) { /* 忽略 */ }
};
pageXhr(url, function (text) {
if (text && String(text).trim()) { finish(text); return; }
if (typeof GM_xmlhttpRequest !== 'function') { finish(''); return; }
try {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
timeout: 20000,
onload: function (res) {
const bufText = bufferToText(res && res.response);
finish(bufText || (res && res.responseText) || '');
},
onerror: function () { finish(''); },
ontimeout: function () { finish(''); }
});
} catch (e) { finish(''); }
}, true);
}
function init() {
// 拖放监听在所有页面都挂上。界面只在确认是本地数据文件,或用户真的拖入了数据文件时才建。
bindDrop();
registerMenus();
exposeApi();
const auto = autoTarget();
if (!auto) return;
// 读到原文再铺界面。提前 buildUi 会把 清掉,后面就补不回来了。
const bootFromText = function (text) {
if (!text || !text.trim()) return false;
capturedSource = text;
ensureUi();
startAutoContent(text, auto);
return true;
};
const fail = function () {
ensureUi();
showEmptyState(auto, '没能从这个页面读到文件原文(浏览器可能把它当成下载处理了)。'
+ '点「📂 打开」选择文件,或直接把 ' + auto + ' 拖进这个页面。');
};
// JSON 查看器同步就把原文放进 ,这条路径不能改成异步,否则会被它抢先冲掉。
if (bootFromText(captureSource(true))) return;
readFileUrl(location.href, function (text) {
if (bootFromText(text) || bootFromText(captureSource(true))) return;
fail();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();