wedata上报事件导出csv

wedata上报事件页面,增加日志的导出csv按钮

// ==UserScript==
// @name         wedata上报事件导出csv
// @version      0.3
// @author       sun
// @description  wedata上报事件页面,增加日志的导出csv按钮
// @match        https://wedata.weixin.qq.com/mp2/report-manage/event/event_monitor*
// @match        https://wedata.weixin.qq.com/mp2/event/data_quality*
// @grant        none
// @license      GPL-3.0
// @run-at       document-end
// @namespace https://greasyfork.org/users/1422627
// ==/UserScript==

;(function () {
	const enter_scene_map = {
		1000: '其他',
		1001: '发现页小程序「最近使用」列表(基础库2.2.4-2.29.0版本包含「我的小程序」列表,2.29.1版本起仅为「最近使用」列表)',
		1005: '微信首页顶部搜索框的搜索结果页',
		1006: '发现栏小程序主入口搜索框的搜索结果页',
		1007: '单人聊天会话中的小程序消息卡片',
		1008: '群聊会话中的小程序消息卡片',
		1010: '收藏夹',
		1011: '扫描二维码',
		1012: '长按图片识别二维码',
		1013: '扫描手机相册中选取的二维码',
		1014: '小程序订阅消息(与1107相同)',
		1017: '前往小程序体验版的入口页',
		1019: '微信钱包(微信客户端7.0.0版本改为支付入口)',
		1020: '公众号 profile 页相关小程序列表(已废弃)',
		1022: '聊天顶部置顶小程序入口(微信客户端6.6.1版本起废弃)',
		1023: '安卓系统桌面图标',
		1024: '小程序 profile 页',
		1025: '扫描一维码',
		1026: '发现栏小程序主入口,「附近的小程序」列表',
		1027: '微信首页顶部搜索框搜索结果页「使用过的小程序」列表',
		1028: '我的卡包',
		1029: '小程序中的卡券详情页',
		1030: '自动化测试下打开小程序',
		1031: '长按图片识别一维码',
		1032: '扫描手机相册中选取的一维码',
		1034: '微信支付完成页',
		1035: '公众号自定义菜单',
		1036: 'App 分享消息卡片',
		1037: '小程序打开小程序',
		1038: '从另一个小程序返回',
		1039: '摇电视',
		1042: '添加好友搜索框的搜索结果页',
		1043: '公众号模板消息',
		1044: '带 shareTicket 的小程序消息卡片 详情',
		1045: '朋友圈广告',
		1046: '朋友圈广告详情页',
		1047: '扫描小程序码',
		1048: '长按图片识别小程序码',
		1049: '扫描手机相册中选取的小程序码',
		1052: '卡券的适用门店列表',
		1053: '搜一搜的结果页',
		1054: '顶部搜索框小程序快捷入口(微信客户端版本6.7.4起废弃)',
		1056: '聊天顶部音乐播放器右上角菜单',
		1057: '钱包中的银行卡详情页',
		1058: '公众号文章',
		1059: '体验版小程序绑定邀请页',
		1060: '微信支付完成页(与1034相同)',
		1064: '微信首页连Wi-Fi状态栏',
		1065: 'URL scheme 详情',
		1067: '公众号文章广告',
		1068: '附近小程序列表广告(已废弃)',
		1069: '移动应用通过openSDK进入微信,打开小程序',
		1071: '钱包中的银行卡列表页',
		1072: '二维码收款页面',
		1073: '客服消息列表下发的小程序消息卡片',
		1074: '公众号会话下发的小程序消息卡片',
		1077: '摇周边',
		1078: '微信连Wi-Fi成功提示页',
		1079: '微信游戏中心',
		1081: '客服消息下发的文字链',
		1082: '公众号会话下发的文字链',
		1084: '朋友圈广告原生页',
		1088: '会话中查看系统消息,打开小程序',
		1089: '微信聊天主界面下拉,「最近使用」栏(基础库2.2.4-2.29.0版本包含「我的小程序」栏,2.29.1版本起仅为「最近使用」栏)',
		1090: '长按小程序右上角菜单唤出最近使用历史',
		1091: '公众号文章商品卡片',
		1092: '城市服务入口',
		1095: '小程序广告组件',
		1096: '聊天记录,打开小程序',
		1097: '微信支付签约原生页,打开小程序',
		1099: '页面内嵌插件',
		1100: '红包封面详情页打开小程序',
		1101: '远程调试热更新(开发者工具中,预览 -> 自动预览 -> 编译并预览)',
		1102: '公众号 profile 页服务预览',
		1103: '发现页小程序「我的小程序」列表(基础库2.2.4-2.29.0版本废弃,2.29.1版本起生效)',
		1104: '微信聊天主界面下拉,「我的小程序」栏(基础库2.2.4-2.29.0版本废弃,2.29.1版本起生效)',
		1106: '聊天主界面下拉,从顶部搜索结果页,打开小程序',
		1107: '订阅消息,打开小程序',
		1113: '安卓手机负一屏,打开小程序(三星)',
		1114: '安卓手机侧边栏,打开小程序(三星)',
		1119: '【企业微信】工作台内打开小程序',
		1120: '【企业微信】个人资料页内打开小程序',
		1121: '【企业微信】聊天加号附件框内打开小程序',
		1124: '扫“一物一码”打开小程序',
		1125: '长按图片识别“一物一码”',
		1126: '扫描手机相册中选取的“一物一码”',
		1129: '微信爬虫访问 详情',
		1131: '浮窗(8.0版本起仅包含被动浮窗)',
		1133: '硬件设备打开小程序 详情',
		1135: '小程序profile页相关小程序列表,打开小程序',
		1144: '公众号文章 - 视频贴片',
		1145: '发现栏 - 发现小程序',
		1146: '地理位置信息打开出行类小程序',
		1148: '卡包-交通卡,打开小程序',
		1150: '扫一扫商品条码结果页打开小程序',
		1151: '发现栏 - 我的订单',
		1152: '订阅号视频打开小程序',
		1153: '“识物”结果页打开小程序',
		1154: '朋友圈内打开“单页模式”',
		1155: '“单页模式”打开小程序',
		1157: '服务号会话页打开小程序',
		1158: '群工具打开小程序',
		1160: '群待办',
		1167: 'H5 通过开放标签打开小程序 详情',
		1168: '移动/网站应用直接运行小程序',
		1169: '发现栏小程序主入口,各个生活服务入口(例如快递服务、出行服务等)',
		1171: '微信运动记录(仅安卓)',
		1173: '聊天素材用小程序打开 详情',
		1175: '视频号主页商店入口',
		1176: '视频号直播间主播打开小程序',
		1177: '视频号直播商品',
		1178: '在电脑打开手机上打开的小程序',
		1179: '#话题页打开小程序',
		1181: '网站应用打开PC小程序',
		1183: 'PC微信 - 小程序面板 - 发现小程序 - 搜索',
		1184: '视频号链接打开小程序',
		1185: '群公告',
		1186: '收藏 - 笔记',
		1187: '浮窗(8.0版本起)',
		1189: '表情雨广告',
		1191: '视频号活动',
		1192: '企业微信联系人profile页',
		1193: '视频号主页服务菜单打开小程序',
		1194: 'URL Link 详情',
		1195: '视频号主页商品tab',
		1196: '个人状态打开小程序',
		1197: '视频号主播从直播间返回小游戏',
		1198: '视频号开播界面打开小游戏',
		1200: '视频号广告打开小程序',
		1201: '视频号广告详情页打开小程序',
		1202: '企微客服号会话打开小程序卡片',
		1203: '微信小程序压测工具的请求',
		1206: '视频号小游戏直播间打开小游戏',
		1207: '企微客服号会话打开小程序文字链',
		1208: '聊天打开商品卡片',
		1212: '青少年模式申请页打开小程序',
		1215: '广告预约打开小程序',
		1216: '视频号订单中心打开小程序',
		1218: '微信键盘预览打开小程序',
		1219: '视频号直播间小游戏一键上车',
		1220: '发现页设备卡片打开小程序',
		1223: '安卓桌面Widget打开小程序',
		1225: '音视频通话打开小程序',
		1226: '聊天消息在设备打开后打开小程序',
		1228: '视频号原生广告组件打开小程序',
		1230: '订阅号H5广告进入小程序',
		1231: '动态消息提醒入口打开小程序',
		1232: '搜一搜竞价广告打开小程序',
		1233: '小程序搜索页人气游戏模块打开小游戏',
		1238: '看一看信息流广告打开小程序',
		1242: '小程序发现页门店快送模块频道页进入小程序',
		1244: '#tag搜索结果页打开小程序',
		1245: '小程序发现页门店快送搜索结果页进入小程序',
		1248: '通过小程序账号迁移进入小程序',
		1252: '搜一搜小程序搜索页「小功能」模块进入小程序',
		1254: '发现页「动态」卡片 打开小程序',
		1255: '发现页「我的」卡片 打开小程序',
		1256: 'pc端小程序面板「最近使用」列表',
		1257: 'pc端小程序面板「我的小程序」列表',
		1258: 'pc端小程序面板「为电脑端优化」模块',
		1259: 'pc端小程序面板「小游戏专区」模块',
		1260: 'pc端小程序面板「推荐在电脑端使用」列表',
		1261: '公众号返佣商品卡片',
		1265: '小程序图片详情页打开小程序',
		1266: '小程序图片长按半屏入口打开小程序',
		1267: '小程序图片会话角标打开小程序',
		1271: '微信聊天主界面下拉,「我的常用小程序」栏',
		1272: '发现页「游戏」服务tab打开小程序',
		1273: '发现页「常用的小程序」列表',
		1278: '发现页「发现小程序」列表打开小程序',
		1279: '发现页「发现小程序」合集页打开小程序',
		1280: '下拉任务栏小程序垂搜「建议使用」打开小程序',
		1281: '下拉任务栏小程序垂搜「发现小程序」打开小程序',
		1282: '听一听播放器打开小程序',
		1285: '发现页「发现小程序」短剧合集打开小程序',
		1286: '明文scheme打开小程序',
		1287: '公众号短剧贴片打开小程序',
		1292: '发现页「发现小程序」poi 详情页打开小程序',
		1293: '发现页短剧卡片追剧页打开小程序',
		1295: '下拉任务栏小程序垂搜「发现小程序」广告打开小程序',
		1296: '视频号付费短剧气泡打开小程序',
		1297: '发现-小程序-搜索「发现小程序」打开小程序',
		1298: '下拉任务栏小程序垂搜「发现小程序」打开的合集访问小程序',
		1299: '下拉任务栏小程序垂搜「发现小程序」poi 详情页打开小程序',
		1300: '发现-小程序-搜索「发现小程序」打开的合集访问小程序',
		1301: '发现-小程序-搜索「发现小程序」poi 详情页打开小程序',
		1302: 'PC端面板「发现小程序」',
		1303: '发现页短剧卡片视频流打开小程序',
		1304: '手机负一屏打开小程序(比如oppo手机)',
		1305: '公众号播放结束页打开小程序',
		1306: '公众号短剧固定选集入口打开小程序',
		1307: '发现页附近服务境外专区打开小程序',
		1308: 'PC端面板小游戏专区页面',
		1309: '公众号文章打开小游戏CPS卡片',
	}
	const tableColumn = [
		{
			key: 'event_time',
		},
		{
			key: 'event_time_desc',
			template: ({ event_time }) => new Date(event_time * 1000).toLocaleString(),
		},
		{
			key: 'eventId',
		},
		{
			key: 'eventDesc',
		},
		{
			key: 'event_value',
		},
		{
			key: 'page_path',
		},
		{
			key: 'openid',
		},
		{
			key: 'appversion',
		},
		{
			key: 'clientversion',
		},
		{
			key: 'devicemodel',
		},
		{
			key: 'devicebrand',
		},
		{
			key: 'enter_scene',
		},
		{
			key: 'enter_scene_desc',
			template: ({ enter_scene }) => enter_scene_map[enter_scene] || '',
		},
		{
			key: 'networktype',
		},
		{
			key: 'os_name',
		},
		{
			key: 'os_version',
		},
		{
			key: 'networktype',
		},
	]
	let globalNode = null
	const observer = new MutationObserver(MutationObserverCallback)
	observer.observe(document.body, { childList: true, subtree: true })
	function MutationObserverCallback(mutationsList, observer) {
		mutationsList.forEach(mutation => {
			if (globalNode) {
				return
			}
			if (mutation.type === 'childList') {
				mutation.addedNodes.forEach(node => {
					if (globalNode) {
						return
					}
					if (node.nodeType === 1 && document.querySelector('#realtime-query-panel')) {
						globalNode = document.querySelector('#realtime-query-panel').parentNode
						setTimeout(() => {
							renderExportButton()
							renderAutoCollectButton()
						}, 100)
					}
				})
			}
		})
	}
	function renderExportButton() {
		const targetElement = document.getElementById('realtime-query-panel')
		if (!targetElement) {
			return
		}
		const button = document.createElement('button')
		button.innerText = '导出CSV'
		button.setAttribute('style', 'margin-left:16px;')
		button.className = `weui-desktop-btn weui-desktop-btn_default`
		button.onclick = function () {
			exportButtonClick()
		}
		targetElement.appendChild(button)
	}
	function exportButtonClick(jsonArray, basicStartTime, basicEndTime) {
		console.log('exportButtonClick:', globalNode.__vue__.logList.length)
		if (!jsonArray) {
			jsonArray = globalNode.__vue__.logList
		}
		if (!basicStartTime) {
			basicStartTime = globalNode.__vue__.basicStartTime
		}
		basicStartTime = new Date(basicStartTime).toLocaleString().split(' ').join('_')
		if (!basicEndTime) {
			basicEndTime = globalNode.__vue__.basicEndTime
		}
		basicEndTime = new Date(basicEndTime).toLocaleString().split(' ').join('_')
		if (jsonArray && jsonArray.length) {
			const csvString = jsonToCsv(jsonArray)
			let eventName = ''
			let elementEventName = Array.from(document.querySelectorAll('[class^="conditionKey_"]')).find(e => e.textContent.trim() === '事件名称')
			if (elementEventName && elementEventName.nextElementSibling) {
				const _value = elementEventName.nextElementSibling.querySelector('input').value
				if (_value) {
					eventName = _value.split(' ').join('_')
				}
			}
			if (!eventName) {
				//单一事件
				elementEventName = document.querySelector('.pageBase__pageTitle-current')
				if (elementEventName) {
					const _value = elementEventName.textContent
					if (_value) {
						eventName = _value.split(' ').join('_')
					}
				}
			}
			const csvName = `统计报表-${eventName}-${jsonArray.length}条-${basicStartTime}-${basicEndTime}.csv`.replace(/\//g, '-')
			downloadCsv(csvString, csvName)
		}
	}
	function renderAutoCollectButton() {
		const targetElement = document.getElementById('realtime-query-panel')
		if (!targetElement) {
			return
		}
		const button = document.createElement('button')
		button.innerText = '自动采集'
		button.id = 'auto-collect-button'
		button.setAttribute('style', 'margin-left:16px;')
		button.className = `weui-desktop-btn weui-desktop-btn_default`
		button.onclick = function () {
			renderDialog()
		}
		targetElement.appendChild(button)
	}
	function renderDialog() {
		const dialog = document.createElement('dialog')
		dialog.style.width = '480px'
		dialog.style.padding = '10px'
		dialog.innerHTML = `
    <form method="dialog">
			<br /><br />
      <label for="start-time">开始时间:</label>
      <input type="datetime-local" id="start-time">
			<br /><br />
      <label for="end-time">结束时间:</label>
      <input type="datetime-local" id="end-time">
			<br /><br />
      <label for="interval-input">间隔时间(秒):</label>
      <input type="number" id="interval-input" min="1" value="86400">
			<br /><br /><br /><br />
      <label>进度与结果:</label>
      <pre id="result-pre" style="max-height: 100px;overflow-y: auto;padding: 10px;background-color: #f4f4f4;border: 1px solid #ccc;white-space: pre-wrap;"></pre>
			<br /><br /><br /><br />
      <div class="buttons">
        <button type="reset" id="cancel-btn">取消</button>
        <button type="submit" id="start-btn">开始</button>
      </div>
    </form>
  `
		document.body.appendChild(dialog)
		setTimeout(() => {
			handleDialogAction(dialog)
		}, 300)
	}
	function handleDialogAction(dialog) {
		dialog.showModal()
		const form = dialog.querySelector('form')
		const resultPre = dialog.querySelector('#result-pre')
		 const now = new Date();
    // 今天 00:00
    const todayMidnight = new Date(now);
    todayMidnight.setHours(0, 0, 0, 0);
    // 昨天 00:00
    const yesterdayMidnight = new Date(todayMidnight);
    yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1);
		dialog.querySelector('#start-time').value =  formatDate(yesterdayMidnight)
		dialog.querySelector('#end-time').value =  formatDate(todayMidnight)
		let result = []
		let loopSwitch = false

		function formatDate(date) {
      const year = date.getFullYear();
      const month = String(date.getMonth() + 1).padStart(2, '0');
      const day = String(date.getDate()).padStart(2, '0');
      const hours = String(date.getHours()).padStart(2, '0');
      const minutes = String(date.getMinutes()).padStart(2, '0');
      return `${year}-${month}-${day}T${hours}:${minutes}`;
    }
		// 新增辅助函数:获取指定日期的 0 点 0 分
		function getMinStartOfDay() {
			const d = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000)
			d.setHours(0, 0, 0, 0)
			return d.getTime()
		}

		// 新增辅助函数:获取指定日期的 24 点
		function getMaxEndOfDay() {
			const d = new Date()
			d.setHours(23, 59, 59, 999)
			return d.getTime()
		}
		// 处理表单提交
		form.addEventListener('submit', async e => {
			e.preventDefault()
			if (loopSwitch) {
				return
			}
      // 2025年6月9日 0点
    //   const date1 = new Date(2025, 5, 16, 0, 0, 0); // 注意:月份从0开始,5表示6月
    //   console.log("2025-06-09 00:00:00 时间戳:", date1.getTime());
      // 2025年6月14日 24点(即6月15日0点)
    //   const date2 = new Date(2025, 5, 23, 0, 0, 0);
    //   console.log("2025-06-14 24:00:00 时间戳:", date2.getTime());
			const startTimeInput = dialog.querySelector('#start-time').value
			const endTimeInput = dialog.querySelector('#end-time').value
			const intervalInput = parseInt(dialog.querySelector('#interval-input').value, 10)

			if (!startTimeInput || !endTimeInput || isNaN(intervalInput)) {
				alert('请填写完整信息')
				return
			}

			const start = new Date(startTimeInput).getTime()
			const end = new Date(endTimeInput).getTime()

			const minStart = getMinStartOfDay()
			if (start < minStart) {
				alert(`开始时间不能早于 ${new Date(minStart).toLocaleString()}`)
				return
			}

			// 设置 end 的最小值为今天的 24 点
			const maxEnd = getMaxEndOfDay()
			if (end > maxEnd) {
				alert(`结束时间不能晚于 ${new Date(maxEnd).toLocaleString()}`)
				return
			}

			if (start >= end) {
				alert('开始时间必须早于结束时间')
				return
			}

			const intervalMs = intervalInput * 1000 // 转为毫秒
			let currentTime = start

			resultPre.textContent = '任务已启动...\n'
			loopSwitch = true
			loopSearch()
			async function loopSearch() {
				if (!loopSwitch) {
					return
				}
				if (currentTime >= end) {
					resultPre.textContent += '\n✅ 所有时间段处理完成。\n'
					loopSwitch = false
					exportButtonClick(result, start, end)
					return
				}
				const endTime = Math.min(currentTime + intervalMs, end)
				console.groupCollapsed(`fetchSearch-${new Date(currentTime).toLocaleString()}-${new Date(endTime).toLocaleString()}`);
				try {
					const { logCount, logLimit, logList } = await fetchSearch(currentTime, endTime)
					result = result.concat(logList)
					resultPre.textContent += `[${new Date(currentTime).toLocaleString()} → ${new Date(endTime).toLocaleString()}] 成功: ${JSON.stringify({
						logCount,
						logLimit,
						logList: logList.length,
					})}\n`
				} catch (err) {
					resultPre.textContent += `[${new Date(currentTime).toLocaleString()} → ${new Date(endTime).toLocaleString()}] ❌ 错误: ${err.message}\n`
				}
				console.groupEnd();
				currentTime += intervalMs
				loopSearch()
			}
			async function fetchSearch(basicStartTime, basicEndTime, retry = 0) {
				globalNode.__vue__.basicStartTime = basicStartTime
				globalNode.__vue__.basicEndTime = basicEndTime
				await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 1001) + 5000))
				try {
					await globalNode.__vue__.onsubmit(false)
					let { logLimit, logCount, logList } = globalNode.__vue__
					if (logCount > logLimit) {
						// 缩短间隔继续请求
						const intervalCount = Math.floor(logCount / logLimit) + 1
						const intervalMs = Math.floor((basicEndTime - basicStartTime) / intervalCount)
						console.warn('logCount > logLimit', {
							logCount,
							logLimit,
							basicStartTime: new Date(basicStartTime).toLocaleString(),
							basicEndTime: new Date(basicEndTime).toLocaleString(),
							intervalCount,
							intervalMs,
						})
						;(logCount = 0), (logList = [])
						for (let i = 0; i < intervalCount; i++) {
							let startTime = basicStartTime + i * intervalMs
							startTime = startTime < basicStartTime ? basicStartTime : startTime
							let endTime = basicStartTime + (i + 1) * intervalMs
							endTime = endTime > basicEndTime ? basicEndTime : endTime
							const { logCount: intervalLogCount, logList: intervalLogList } = await fetchSearch(startTime, endTime, 0)
							logCount += intervalLogCount
							logList = logList.concat(intervalLogList)
							console.warn('logCount > logLimit-', i, {
								intervalLogCount,
								startTime: new Date(startTime).toLocaleString(),
								endTime: new Date(endTime).toLocaleString(),
							})
						}
					}else{
						logList = [...logList].reverse()
					}
					return {
						logLimit,
						logCount,
						logList,
					}
				} catch (error) {
					if (retry >= 5) {
						throw error
					} else {
						return fetchSearch(basicStartTime, basicEndTime, retry + 1) // 重试
					}
				}
			}
		})

		// 取消按钮逻辑
		form.querySelector('#cancel-btn').addEventListener('click', () => {
			loopSwitch = false
			resultPre.textContent += '\n❌ 已取消当前任务。\n'
			dialog.close()
			//移除dialog
			setTimeout(() => {
				document.body.removeChild(dialog)
				dialog = null
			}, 300)
		})
	}

	function jsonToCsv(jsonArray) {
		const headers = tableColumn.map(e => e.key)
		const csvRows = []
		csvRows.push(headers.join(','))
		for (const row of jsonArray) {
			const values = tableColumn.map(column => {
				const value = column.template?.(row) || row[column.key]
				return `"${value}"` // 保证每个值都用双引号包裹,避免包含逗号等特殊字符
			})
			csvRows.push(values.join(','))
		}
		return csvRows.join('\n')
	}

	// 将 CSV 数据写入文件(考虑 UTF-8 编码)
	function downloadCsv(csvString, filename) {
		const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8' })
		const link = document.createElement('a')
		link.href = URL.createObjectURL(blob)
		link.download = filename
		link.click()
	}
})()