您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds an export button to the daily pulse ox page; exports to CSV or JSON
// ==UserScript== // @name Garmin Connect: Export Pulse Ox Data // @namespace http://tampermonkey.net/ // @description Adds an export button to the daily pulse ox page; exports to CSV or JSON // @author You // @match https://connect.garmin.com/modern/* // @icon https://www.google.com/s2/favicons?sz=64&domain=garmin.com // @grant window.onurlchange // @license MIT // @version 0.10 // ==/UserScript== (function () { 'use strict'; // https://connect.garmin.com/modern/pulse-ox/DATE // https://connect.garmin.com/modern/pulse-ox-acclimation/DATE const urlPrefix = 'https://connect.garmin.com/modern/pulse-ox' let currentPageMatchesUrl = false; const toggleQuery = '#pageContainer .navButtons'; let tasks = [] // =================================================================== function loadCss(url) { const fileref = document.createElement("link") fileref.rel = "stylesheet"; fileref.type = "text/css"; fileref.href = url document.head.appendChild(fileref); } //https://stackoverflow.com/a/31374433 const loadJS = function(url, location, onload, onerror){ //url is URL of external file, onload, onerror is the code //to be called from the file, location is the location to //insert the <script> element var scriptTag = document.createElement('script'); scriptTag.src = url; scriptTag.onload = onload; // scriptTag.onreadystatechange = implementationCode; scriptTag.onerror= onerror ; location.appendChild(scriptTag); }; let haveNotyf = false; const onLoadJs = function() { haveNotyf = true; waitForUrl() }; const onLoadJsFailed = function() { waitForUrl() }; //https://github.com/caroso1222/notyf loadCss('https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css'); loadJS('https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js', document.body, onLoadJs, onLoadJsFailed); // =================================================================== function waitForUrl() { // if (window.onurlchange == null) { // feature is supported window.addEventListener('urlchange', onUrlChange); // } onUrlChange(); } function onUrlChange() { const urlMatches = window.location.href.startsWith(urlPrefix); if (!currentPageMatchesUrl) { if (urlMatches) { currentPageMatchesUrl = true; init(); } } else { if (!urlMatches) { currentPageMatchesUrl = false; deinit(); } else { deinit(); init(); } } } function init() { tasks = []; tasks.push(runWhenReady(toggleQuery, installHandler)); } function deinit() { tasks.forEach(task => task.stop()); tasks = []; } function runWhenReady(readySelector, callback) { let numAttempts = 0; let timer = undefined const tryNow = function () { const elem = document.querySelector(readySelector); if (elem) { callback(elem); } else { numAttempts++; if (numAttempts >= 34) { console.warn('Giving up after 34 attempts. Could not find: ' + readySelector); } else { timer = setTimeout(tryNow, 250 * Math.pow(1.1, numAttempts)); } } }; const stop = function () { clearTimeout(timer); timer = undefined } tryNow(); return { stop } } // ============================================================= function installHandler() { const pulseox_btn_id = '_export_pulseox_btn'; if (!document.getElementById(pulseox_btn_id)) { // pulse ox page let navButtons = document.querySelector('span.navButtons'); if (navButtons) { const parentNode = navButtons.parentNode; const todayButton = parentNode.querySelector('button'); const exportButton = todayButton.cloneNode(); exportButton.id = pulseox_btn_id; exportButton.innerText = "Export"; exportButton.disabled = null; exportButton.addEventListener('click', exportPulseox) parentNode.insertBefore(exportButton, todayButton); } else { // pulse ox acclimation page navButtons = document.querySelector('div.navButtons'); if (navButtons) { const exportButton = document.createElement('button') exportButton.id = pulseox_btn_id; exportButton.innerText = "Export"; exportButton.disabled = null; exportButton.style = ` display: inline-flex; align-items: center; justify-content: center; flex-direction: row; gap: 8px; margin: 0; border: none; border-radius: var(--sizing-spacing-x-small); color: white; font-weight: 600; transition: background-color 200ms, outline 50ms; cursor: pointer; min-width: 24px; min-height: 24px; height: fit-content; background-color: var(--accent-fills-light); padding: var(--sizing-spacing-x-small) var(--sizing-spacing-medium); font-size: 12px; line-height: 20px; margin-right: 7px; ` exportButton.addEventListener('click', exportPulseox) navButtons.insertBefore(exportButton, navButtons.querySelector('button')); } } } } // ============================================================= function exportPulseox() { const loc = window.location.href const connectURL = "https://connect.garmin.com"; const dailyURL = "https://connect.garmin.com/modern/pulse-ox/" const otherDailyURL = "https://connect.garmin.com/modern/pulse-ox-acclimation/" if (loc.indexOf(connectURL) != 0 || typeof jQuery === "undefined" || !localStorage.token) { alert( `You must be logged into Garmin Connect to run this script. Log into ${connectURL} and try again.` ); return; } // Garmin Connect uses jQuery, so it's available for this script // (but really it should be rewritten so it doesn't use jquery - TODO) jQuery("#_gc-pulseox_modal").remove(); _gcExportPulseox(); function _gcExportPulseox() { let today = new Date(); let haveDate = false; if (loc.indexOf(dailyURL) == 0 || loc.indexOf(otherDailyURL) == 0) { haveDate = true; let todayStr = loc.replace(otherDailyURL, "").replace(dailyURL, ""); const dateRegExp = /^(\d\d\d\d)-(\d\d)-(\d\d)/; const match = todayStr.match(dateRegExp); if (match && match.length !== 0) { today = new Date(match[1], match[2]-1, match[3]); } } let startDate = formatDate(today); if (!haveDate) { let date = promptDate( `Export Garmin Connect Pulse Ox data Enter date to export (YYYY-MM-DD): `, startDate ) if (!date) { return; } startDate = formatDate(date); } const xhr = new XMLHttpRequest(); xhr.open('GET', `https://connect.garmin.com/wellness-service/wellness/daily/spo2acclimation/${startDate}`); xhr.setRequestHeader("NK", "NT") xhr.setRequestHeader('di-backend', 'connectapi.garmin.com') xhr.setRequestHeader('Authorization', `Bearer ${JSON.parse(localStorage.token).access_token}`); xhr.onload = function () { if (xhr.status !== 200) { alert(`⚠️ Error exporting data: ${xhr.status} ${xhr.statusText}\n\nMake sure you are logged into Garmin Connect and try again.`) return; } let obj = JSON.parse(xhr.response) addDialog(obj, startDate) }; xhr.onerror = function(error) { alert(`⚠️ Error exporting data: ${error}\n\nnMake sure you are logged into Garmin Connect and try again.`) } xhr.send() } function formatDate(date) { let d = new Date(date), month = '' + (d.getMonth() + 1), day = '' + d.getDate(), year = d.getFullYear(); if (month.length < 2) month = '0' + month; if (day.length < 2) day = '0' + day; return [year, month, day].join('-'); } function formatDateAndTime(date) { let d = new Date(date), month = '' + (d.getMonth() + 1), day = '' + d.getDate(), year = d.getFullYear(); if (month.length < 2) month = '0' + month; if (day.length < 2) day = '0' + day; return `${[year, month, day].join('-')} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`; } function promptDate(str, def) { while (true) { const val = prompt(str, def); if (!val) { return val; } const dateRegExp = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; const match = val.match(dateRegExp); if (!match || match.length == 0) { continue; } const d = new Date(match[1], match[2]-1, match[3], 0, 0, 0); // console.log(d) return d; } } function formatCSVPercentage(v) { if (v === null || v === undefined) { return ''; } return `${v}%` } function formatCSVNumber(v) { if (v === null || v === undefined) { return ''; } return Math.round(v * 100) / 100; } function getCSV(data) { let csv = `Section,Date/Time,Single Pulse Ox Reading (%),Pulse Ox Hourly Average (%),Elevation (m),Summary Label,Summary Value\n`; csv += `Summary,,,,,\n`; csv += `,,,,,File Description,Pulse Ox Export (single readings + hourly averages)\n`; csv += `,,,,,Date,${data.calendarDate}\n`; csv += `,,,,,Average Pulse Ox (today),${formatCSVPercentage(data.averageSpO2)}\n`; csv += `,,,,,Lowest Pulse Ox (today),${formatCSVPercentage(data.lowestSpO2)}\n`; csv += `,,,,,Average Sleep Pulse Ox (today),${formatCSVPercentage(data.avgSleepSpO2)}\n`; csv += `,,,,,Average Sleep Pulse Ox (tomorrow),${formatCSVPercentage(data.avgTomorrowSleepSpO2)}\n`; csv += `,,,,,Average Pulse Ox (last 7 days),${formatCSVPercentage(data.lastSevenDaysAvgSpO2)}\n`; csv += `Pulse Ox (Single Readings),,,,,\n`; for (const val of data.spO2SingleValues || []) { const d = new Date(0); d.setUTCSeconds(val[0] / 1000); csv += `,${formatDateAndTime(d)},${formatCSVPercentage(val[1])},,,,\n`; } csv += `Pulse Ox Acclimation (Hourly Averages / Elevation),,,,,\n`; for (const val of data.spO2HourlyAverages || []) { var d = new Date(0); d.setUTCSeconds(val[0] / 1000); csv += `,${formatDateAndTime(d)},,${formatCSVPercentage(val[1])},${formatCSVNumber(val[2])},,\n`; } return csv; } function addDialog(data, startDate) { _addDialog(data, startDate, { csv_filename: `pulse-ox-export-${startDate}.csv`, json_filename: `pulse-ox-json-export-${startDate}.txt`, modal_id: '_gc-pulseox_modal', modal_class: '_gc-pulseox-modalDialog', title: `Garmin Pulse Ox Data: ${startDate}`, }) } // ============================== // generic code below function _addDialog(data, startDate, options) { const { csv_filename, json_filename, modal_id, modal_class, title, } = options; _addCSS(modal_class); jQuery(`#${modal_id}`).remove(); const output = JSON.stringify(data, null, 2); // console.log(data) // DEBUG const csv = getCSV(data); jQuery('body').append(` <div id="${modal_id}" class="${modal_class}"> <div class="_gc-modal-inner"> <a href="#" title="Close" class="_gc-modal-close">X</a> <h2>${title}</h2> <b>CSV (edited)</b><br> • can be opened in Excel / Numbers / Google Sheets<br> • this is an edited form of the original JSON data (below)<br> • does not contain user profile ID<br> <textarea readonly class="_gc-modal-csv-textarea" rows="4" style="width:100%" spellcheck="false" >${csv}</textarea> <br> <br> <div> <div style="float:left"> <button class="_gc-misc-btn _gc-modal-csv-copy" style="margin-right: 5px">Copy CSV to Clipboard</button> <span class="_gc-modal-csv-copied fade"><b>CSV data copied to clipboard 👍</b></span> </div> <div style="float: right"> <a class="_gc-primary-btn _gc-misc-btn" download='${csv_filename}' href='data:text/plain;charset=utf-8,${encodeURIComponent(csv)}'>Download CSV</a> </div> <div style="clear:both"></div> </div> <div style="margin-top: 5px"> <span style="float: right"> <b>You probably want to press this button 👆</b> </span> <div style="clear:both"></div> </div> <hr> <b>JSON (original)</b><br> • can't be opened in Excel / Numbers / Google Sheets<br> • contains original, unedited data from Garmin API<br> • ⚠️ contains user profile ID which uniquely identifies your Connect account<br> <textarea readonly class="_gc-modal-json-textarea" rows="4" style="width:100%" spellcheck="false" >${output}</textarea> <br> <br> <div> <div style="float:left"> <button class="_gc-misc-btn _gc-modal-json-copy" style="margin-right: 5px">Copy JSON to Clipboard</button> <span class="_gc-modal-json-copied fade"><b>JSON data copied to clipboard 👍</b></span> </div> <div style="float:right"> <a class="_gc-misc-btn" download='${json_filename}' href='data:text/plain;charset=utf-8,${encodeURIComponent(output)}'>Download JSON</a> </div> <div style="clear:both"></div> </div> </div> </div> `); function closeModal() { jQuery(`#${modal_id}`).remove(); window.removeEventListener("keydown", onKeydown) } window.addEventListener("keydown", onKeydown) function onKeydown(e) { console.log('keydown') console.log(e) if (e.keyCode === 27) { closeModal(); } } let notyf = haveNotyf ? new Notyf({position: {x: 'center', y: 'bottom'}}) : null; jQuery(`#${modal_id}`).click(function (e) { closeModal(); return false; }) jQuery(`#${modal_id} ._gc-modal-inner`).click(function (e) { e.stopPropagation(); }) jQuery(`#${modal_id} ._gc-modal-close`).click(function() { closeModal(); return false; }); jQuery(`#${modal_id} ._gc-modal-json-copy`).click(function() { let el = jQuery(`#${modal_id} ._gc-modal-json-textarea`); el.select(); document.execCommand('copy'); if (notyf) { notyf.success('JSON data copied to clipboard') } else { jQuery(`#${modal_id} ._gc-modal-json-copied`).addClass('show'); setTimeout(() => { jQuery(`#${modal_id} ._gc-modal-json-copied`).removeClass('show'); }, 2000); } return false; }); jQuery(`#${modal_id} ._gc-modal-csv-copy`).click(function() { let el = jQuery(`#${modal_id} ._gc-modal-csv-textarea`); el.select(); document.execCommand('copy'); if (notyf) { notyf.success('CSV data copied to clipboard') } else { jQuery(`#${modal_id} ._gc-modal-csv-copied`).addClass('show'); setTimeout(() => { jQuery(`#${modal_id} ._gc-modal-csv-copied`).removeClass('show'); }, 2000); } return false; }); } function _addCSS(modal_class) { // based on https://jsfiddle.net/kumarmuthaliar/GG9Sa/1/ const modal_zindex = 99999; const styles = ` .notyf { z-index: ${modal_zindex+1} !important; } .${modal_class} { position: fixed; font-family: Arial, Helvetica, sans-serif; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0, 0, 0, 0.8); z-index: ${modal_zindex}; // opacity:0; -webkit-transition: opacity 400ms ease-in; -moz-transition: opacity 400ms ease-in; transition: opacity 400ms ease-in; } .${modal_class} > div { width: 600px; position: relative; margin: 20px auto; padding: 5px 20px 13px 20px; border-radius: 10px; background: #eee; /*background: -moz-linear-gradient(#fff, #999); background: -webkit-linear-gradient(#fff, #999); background: -o-linear-gradient(#fff, #999);*/ } .${modal_class} ._gc-modal-close { background: #606061; color: #FFFFFF; line-height: 25px; position: absolute; right: -12px; text-align: center; top: -10px; width: 24px; text-decoration: none; font-weight: bold; -webkit-border-radius: 12px; -moz-border-radius: 12px; border-radius: 12px; -moz-box-shadow: 1px 1px 3px #000; -webkit-box-shadow: 1px 1px 3px #000; box-shadow: 1px 1px 3px #000; } .${modal_class} ._gc-modal-close:hover { background: #00d9ff; } .${modal_class} ._gc-primary-btn, .${modal_class} ._gc-primary-btn:hover, .${modal_class} ._gc-primary-btn:visited, .${modal_class} ._gc-primary-btn:active { color: #fff; background-color: #337ab7 !important; border-color: #2e6da4 !important; } .${modal_class} ._gc-misc-btn, .${modal_class} ._gc-misc-btn:hover, .${modal_class} ._gc-misc-btn:visited, .${modal_class} ._gc-misc-btn:active { color: #fff; text-decoration:none; background-color: #6c757d; border-color: #6c757d; display: inline-block; margin-bottom: 0; font-weight: 400; text-align: center; white-space: nowrap; vertical-align: middle; -ms-touch-action: manipulation; touch-action: manipulation; cursor: pointer; background-image: none; border: 1px solid transparent; border-top-color: transparent; border-right-color: transparent; border-bottom-color: transparent; border-left-color: transparent; padding: 6px 12px; font-size: 14px; line-height: 1.42857143; border-radius: 4px; } .${modal_class} ._gc-modal-json-textarea, .${modal_class} ._gc-modal-csv-textarea { font-family: "Lucida Console", Monaco, Monospace } .${modal_class} .fade { /* use bootstrap default (.15s) */ transition: opacity .15s linear; opacity: 0; } .${modal_class} .fade.show { opacity: 1; display: inline; } `; const stylesheetId = `${modal_class}_styles` jQuery(`#${stylesheetId}`).remove(); const styleSheet = document.createElement("style") // styleSheet.type = "text/css"; styleSheet.id = stylesheetId; styleSheet.innerText = styles document.head.appendChild(styleSheet); } } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址