UNPKG

@gooin/garmin-connect

Version:

Makes it simple to interface with Garmin Connect to get or set any data point

577 lines 25.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HttpClient = void 0; const axios_1 = __importDefault(require("axios")); const tough_cookie_1 = require("tough-cookie"); const form_data_1 = __importDefault(require("form-data")); const lodash_1 = __importDefault(require("lodash")); const luxon_1 = require("luxon"); const oauth_1_0a_1 = __importDefault(require("oauth-1.0a")); const qs_1 = __importDefault(require("qs")); const node_crypto_1 = __importDefault(require("node:crypto")); const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"'); const PAGE_TITLE_RE = new RegExp('<title>([^<]*)</title>'); const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; const USER_AGENT_CONNECT_IOS = 'GCM-iOS-5.7.2.1'; const USER_AGENT_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36'; const USER_AGENT_BROWSER_MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json'; const HTTP_STATUS = { UNAUTHORIZED: 401 }; let tokenRefreshPromise = null; let refreshSubscribers = []; class HttpClient { constructor(url, config) { var _a, _b; const jar = new tough_cookie_1.CookieJar(); this.url = url; this.client = axios_1.default.create({ timeout: (_a = config === null || config === void 0 ? void 0 : config.timeout) !== null && _a !== void 0 ? _a : 5000, timeoutErrorMessage: `Request Timeout: > ${(_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : 5000} ms`, maxRedirects: 10, validateStatus: function (status) { return status >= 200 && status < 400; }, withCredentials: true, jar: jar, }); this.config = config; this.client.interceptors.response.use((response) => { var _a, _b, _c, _d; // 跟踪重定向过程 if (((_a = response.config.url) === null || _a === void 0 ? void 0 : _a.includes('signin')) || ((_b = response.config.url) === null || _b === void 0 ? void 0 : _b.includes('verifyMFA'))) { console.log('> 响应跟踪 - URL:', response.config.url); console.log('响应跟踪 - 状态码:', response.status); console.log('响应跟踪 - 最终URL:', ((_c = response.request) === null || _c === void 0 ? void 0 : _c.responseURL) || response.config.url); console.log('响应跟踪 - 重定向次数:', ((_d = response.request) === null || _d === void 0 ? void 0 : _d.redirectCount) || 0); // 检查是否有Location头 if (response.headers.location) { console.log('响应跟踪 - Location头:', response.headers.location); } // 检查响应头中可能的重定向信息 if (response.status >= 300 && response.status < 400) { console.log('响应跟踪 - 检测到重定向状态码:', response.status); } } return response; }, async (error) => { var _a, _b, _c, _d; if (axios_1.default.isAxiosError(error) && error.code === 'ECONNABORTED') { throw new Error(error.message || 'Request Timeout'); } const originalRequest = error.config; if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === HTTP_STATUS.UNAUTHORIZED && !(originalRequest === null || originalRequest === void 0 ? void 0 : originalRequest._retry)) { if (!this.oauth2Token) { throw new Error('No OAuth2 token available'); } originalRequest._retry = true; try { if (!tokenRefreshPromise) { tokenRefreshPromise = this.refreshOauth2Token().finally(() => { tokenRefreshPromise = null; }); } await tokenRefreshPromise; originalRequest.headers.Authorization = `Bearer ${this.oauth2Token.access_token}`; return this.client(originalRequest); } catch (err) { console.error('Token refresh failed:', err); throw err; } } if (axios_1.default.isAxiosError(error) && error.response) { // 添加错误响应的调试信息 if (((_b = error.response.config.url) === null || _b === void 0 ? void 0 : _b.includes('signin')) || ((_c = error.response.config.url) === null || _c === void 0 ? void 0 : _c.includes('verifyMFA'))) { console.log('> HTTP Error响应跟踪 - URL:', error.response.config.url); console.log('HTTP Error响应跟踪 - 状态码:', error.response.status); console.log('HTTP Error响应跟踪 - 最终URL:', ((_d = error.response.request) === null || _d === void 0 ? void 0 : _d.responseURL) || error.response.config.url); if (error.response.headers.location) { console.log('HTTP Error响应跟踪 - Location头:', error.response.headers.location); } } this.handleError(error.response); } else { // 处理没有response的情况 throw new Error('Network error or unknown error occurred'); } throw error; }); this.client.interceptors.request.use(async (config) => { if (this.oauth2Token) { config.headers.Authorization = 'Bearer ' + this.oauth2Token.access_token; } return config; }); } async fetchOauthConsumer() { const response = await axios_1.default.get(OAUTH_CONSUMER_URL); this.OAUTH_CONSUMER = { key: response.data.consumer_key, secret: response.data.consumer_secret }; } async checkTokenVaild() { if (this.oauth2Token) { if (this.oauth2Token.expires_at < luxon_1.DateTime.now().toSeconds()) { console.error('Token expired!'); await this.refreshOauth2Token(); } } } async get(url, config) { const response = await this.client.get(url, config); return response === null || response === void 0 ? void 0 : response.data; } async post(url, data, config) { var _a, _b; try { const response = await this.client.post(url, data, config); // 如果是MFA验证请求,添加额外的调试信息 if (url.includes('verifyMFA')) { console.log('MFA验证响应状态码:', response.status); console.log('MFA验证响应头 Location:', response.headers.location); console.log('MFA验证响应头 Set-Cookie:', response.headers['set-cookie']); console.log('MFA验证最终URL:', ((_a = response.request) === null || _a === void 0 ? void 0 : _a.responseURL) || response.config.url); // 检查是否重定向到了包含logintoken的URL if (((_b = response.request) === null || _b === void 0 ? void 0 : _b.responseURL) && response.request.responseURL.includes('logintoken')) { console.log('检测到重定向到包含logintoken的URL:', response.request.responseURL); // 如果重定向到了包含logintoken的URL,我们需要获取重定向后的页面内容 // 因为Axios可能没有自动跟随重定向,我们需要手动获取 if (!response.data.includes('Success') && !response.data.includes('ticket=')) { console.log('响应数据不包含成功标识,手动获取重定向后的页面内容'); const redirectResponse = await this.client.get(response.request.responseURL, { headers: { 'User-Agent': USER_AGENT_CONNECT_IOS } }); console.log('手动获取重定向页面状态码:', redirectResponse.status); console.log('手动获取重定向页面长度:', redirectResponse.data.length); return redirectResponse.data; } } } return response.data; } catch (error) { console.error('POST请求失败:', error); throw error; } } async put(url, data, config) { const response = await this.client.put(url, data, config); return response === null || response === void 0 ? void 0 : response.data; } async delete(url, config) { const response = await this.client.post(url, null, { ...config, headers: { ...config === null || config === void 0 ? void 0 : config.headers, 'X-Http-Method-Override': 'DELETE' } }); return response === null || response === void 0 ? void 0 : response.data; } setCommonHeader(headers) { lodash_1.default.each(headers, (headerValue, key) => { this.client.defaults.headers.common[key] = headerValue; }); } handleError(response) { this.handleHttpError(response); } handleHttpError(response) { const { status, statusText, data } = response; const errorMessage = { status, statusText, data: typeof data === 'object' ? JSON.stringify(data) : data }; console.error('HTTP Error:', errorMessage); throw new Error(`HTTP Error (${status}): ${statusText}`); } /** * Login to Garmin Connect * @param username * @param password * @param mfaCallback Optional MFA callback function * @returns {Promise<{oauth1: IOauth1; oauth2: IOauth2Token}>} */ async login(username, password, mfaCallback) { try { // 获取OAuth Consumer信息 if (!this.OAUTH_CONSUMER) { await this.fetchOauthConsumer(); } // 定义参数,与Python版本保持一致 const SSO = this.url.GARMIN_SSO; const SSO_EMBED = this.url.GARMIN_SSO_EMBED; const SSO_EMBED_PARAMS = { id: 'gauth-widget', embedWidget: 'true', gauthHost: SSO }; const SIGNIN_PARAMS = { id: 'gauth-widget', embedWidget: true, clientId: 'GarminConnect', locale: 'en', gauthHost: this.url.GARMIN_SSO_EMBED, service: this.url.GARMIN_SSO_EMBED, source: this.url.GARMIN_SSO_EMBED, redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED, redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED }; // 设置cookies - 与Python版本一致 await this.get(`${SSO}/embed`, { params: SSO_EMBED_PARAMS, headers: { 'User-Agent': USER_AGENT_CONNECT_IOS } }); // 获取CSRF令牌 - 与Python版本一致 const signinResponse = await this.get(`${SSO}/signin`, { params: SIGNIN_PARAMS, headers: { 'User-Agent': USER_AGENT_CONNECT_IOS, Referer: `${SSO}/embed` } }); // 保存登录页面HTML用于调试 this.saveHtmlToFile(signinResponse, 'signin_page'); const csrfToken = this.extractCsrfToken(signinResponse); console.log('🚀 - login - csrfToken:', csrfToken); if (!csrfToken) { throw new Error('无法从登录页面提取CSRF令牌'); } // 提交登录表单 - 使用FormData方式,与旧版本一致 const form = new form_data_1.default(); form.append('username', username); form.append('password', password); form.append('embed', 'true'); form.append('_csrf', csrfToken); const loginResponse = await this.post(`${SSO}/signin`, form, { params: SIGNIN_PARAMS, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Dnt': 1, 'Origin': this.url.GARMIN_SSO_ORIGIN, 'Referer': `${SSO}/signin`, 'User-Agent': USER_AGENT_CONNECT_IOS }, maxRedirects: 10 // 确保跟随重定向 }); ``; // 保存登录响应HTML用于调试 this.saveHtmlToFile(loginResponse, 'login_response'); const title = this.extractPageTitle(loginResponse); console.log('登录后页面标题:', title); // 处理MFA - 与Python版本一致 // 使用与Python版本相同的关键词检测MFA需求 const mfaKeywords = ['MFA', 'Enter security code', 'Enter MFA code']; const needsMfa = mfaKeywords.some(keyword => title && title.toLowerCase().includes(keyword.toLowerCase())); let finalResponse = loginResponse; // 默认使用登录响应 if (needsMfa) { if (!mfaCallback) { throw new Error('需要MFA验证但未提供回调函数'); } console.log('检测到需要MFA验证,页面标题:', title); // 处理MFA验证 finalResponse = await this.handleMfaVerification(loginResponse, SIGNIN_PARAMS, mfaCallback); // 检查MFA验证后的响应是否包含成功标识 const mfaResultTitle = this.extractPageTitle(finalResponse); console.log('MFA验证后页面标题:', mfaResultTitle); if (mfaResultTitle !== 'Success' && !finalResponse.includes('ticket=')) { throw new Error(`MFA验证后未获得成功页面,当前页面: ${mfaResultTitle}`); } } else if (title !== 'Success') { throw new Error(`登录失败,页面标题: ${title}`); } // 提取ticket - 从最终响应中提取 const ticket = this.extractTicket(finalResponse); if (!ticket) { throw new Error('无法从响应中提取ticket'); } // 获取OAuth1令牌 const oauth1 = await this.getOauth1Token(ticket); // 交换OAuth2令牌 await this.exchange(oauth1); return { oauth1, oauth2: this.oauth2Token }; } catch (error) { console.error('登录失败:', error); throw error; } } /** * 保存HTML内容到文件,用于调试 * @param htmlContent HTML内容 * @param filename 文件名(不含扩展名) */ saveHtmlToFile(htmlContent, filename) { try { const fs = require('fs'); const path = require('path'); const debugDir = path.join(process.cwd(), 'debug'); // 确保调试目录存在 if (!fs.existsSync(debugDir)) { fs.mkdirSync(debugDir, { recursive: true }); } const filePath = path.join(debugDir, `${filename}.html`); fs.writeFileSync(filePath, htmlContent); console.log(`已保存调试文件: ${filePath}`); } catch (error) { console.error('保存调试文件失败:', error); } } /** * 处理MFA验证 * @param loginResponse 登录响应HTML * @param signinParams 登录参数 * @param mfaCallback MFA验证码回调函数 * @returns MFA验证响应 */ async handleMfaVerification(loginResponse, signinParams, mfaCallback) { try { // 保存MFA页面HTML用于调试 this.saveHtmlToFile(loginResponse, 'mfa_page'); // 提取CSRF令牌 const csrfToken = this.extractCsrfToken(loginResponse); if (!csrfToken) { throw new Error('无法从MFA页面提取CSRF令牌'); } // 获取MFA验证码 const mfaCode = await mfaCallback(); if (!mfaCode) { throw new Error('未提供MFA验证码'); } // 处理MFA验证 - 使用FormData方式,与旧版本一致 const SSO = this.url.GARMIN_SSO; const mfaForm = new form_data_1.default(); mfaForm.append('mfa-code', mfaCode); mfaForm.append('embed', 'true'); mfaForm.append('_csrf', csrfToken); const mfaResult = await this.post(`${SSO}/mfa/verify`, mfaForm, { params: signinParams, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Dnt': 1, 'Origin': this.url.GARMIN_SSO_ORIGIN, 'Referer': `${SSO}/signin`, 'User-Agent': USER_AGENT_BROWSER }, maxRedirects: 10, // 添加响应拦截器,确保获取重定向后的最终响应 transformResponse: [ function (data, headers) { // 检查是否有重定向 if (headers.location && headers.location.includes('logintoken')) { console.log('检测到重定向到包含logintoken的URL:', headers.location); } return data; } ] }); console.log('MFA验证完成,响应长度:', mfaResult.length); // 保存MFA验证响应用于调试 this.saveHtmlToFile(mfaResult, 'mfa_verification_response'); // 检查MFA验证后的页面标题 const mfaResultTitle = this.extractPageTitle(mfaResult); console.log('MFA验证后的页面标题:', mfaResultTitle); // 验证MFA是否成功 if (mfaResultTitle !== 'Success' && !mfaResult.includes('ticket=')) { throw new Error(`MFA验证失败,页面标题: ${mfaResultTitle}`); } // 如果MFA验证成功,更新loginResponse为MFA验证结果 // 这样后续的ticket提取将从MFA验证后的响应中进行 // 注意:这里我们不能直接修改loginResponse参数,但可以在返回值中提供MFA验证结果 return mfaResult; } catch (error) { console.error('MFA验证失败:', error); throw error; } } /** * 从HTML中提取CSRF令牌 * @param html HTML字符串 * @returns CSRF令牌或null */ extractCsrfToken(html) { const match = CSRF_RE.exec(html); return match ? match[1] : null; } /** * 从HTML中提取页面标题 * @param html HTML字符串 * @returns 页面标题或空字符串 */ extractPageTitle(html) { const match = PAGE_TITLE_RE.exec(html); return match ? match[1] : ''; } /** * 从HTML中提取ticket * @param html HTML字符串 * @returns ticket或null */ extractTicket(html) { const match = TICKET_RE.exec(html); return match ? match[1] : null; } /** * 处理页面标题 * @param htmlStr HTML字符串 * @returns 页面标题 */ handlePageTitle(htmlStr) { const pageTitileRegResult = PAGE_TITLE_RE.exec(htmlStr); if (pageTitileRegResult) { const title = pageTitileRegResult[1]; console.log('login page title:', title); if (lodash_1.default.includes(title, 'Update Phone Number')) { // current I don't know where to update it // See: https://github.com/matin/garth/issues/19 throw new Error('login failed (Update Phone number), please update your phone number, See: https://github.com/matin/garth/issues/19'); } return title; } else { return ''; } } handleAccountLocked(htmlStr) { const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr); if (accountLockedRegResult) { const msg = accountLockedRegResult[1]; console.error(msg); throw new Error('login failed (AccountLocked), please open connect web page to unlock your account'); } } async refreshOauth2Token() { try { if (!this.OAUTH_CONSUMER) { await this.fetchOauthConsumer(); } if (!this.oauth2Token || !this.oauth1Token) { throw new Error('Missing required tokens for refresh'); } const oauth1 = { oauth: this.getOauthClient(this.OAUTH_CONSUMER), token: this.oauth1Token }; await this.exchange(oauth1); console.log(`「${this.config.username}」in「${this.url.domain}」 OAuth2 token refreshed successfully`); } catch (error) { console.error('Failed to refresh OAuth2 token:', error); throw error; } } async getOauth1Token(ticket) { if (!this.OAUTH_CONSUMER) { throw new Error('No OAUTH_CONSUMER'); } const params = { ticket, 'login-url': this.url.GARMIN_SSO_EMBED, 'accepts-mfa-tokens': true }; const url = `${this.url.OAUTH_URL}/preauthorized?${qs_1.default.stringify(params)}`; const oauth = this.getOauthClient(this.OAUTH_CONSUMER); const step4RequestData = { url: url, method: 'GET' }; const headers = oauth.toHeader(oauth.authorize(step4RequestData)); // console.log('getOauth1Token - headers:', headers); const response = await this.get(url, { headers: { ...headers, 'User-Agent': USER_AGENT_CONNECTMOBILE } }); // console.log('getOauth1Token - response:', response); const token = qs_1.default.parse(response); // console.log('getOauth1Token - token:', token); this.oauth1Token = token; return { token, oauth }; } getOauthClient(consumer) { const oauth = new oauth_1_0a_1.default({ consumer: consumer, signature_method: 'HMAC-SHA1', hash_function(base_string, key) { return node_crypto_1.default .createHmac('sha1', key) .update(base_string) .digest('base64'); } }); return oauth; } // async exchange(oauth1) { const token = { key: oauth1.token.oauth_token, secret: oauth1.token.oauth_token_secret }; // console.log('exchange - token:', token); const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; const requestData = { url: baseUrl, method: 'POST', data: null }; const step5AuthData = oauth1.oauth.authorize(requestData, token); // console.log('login - step5AuthData:', step5AuthData); const url = `${baseUrl}?${qs_1.default.stringify(step5AuthData)}`; // console.log('exchange - url:', url); this.oauth2Token = undefined; const response = await this.post(url, null, { headers: { 'User-Agent': USER_AGENT_CONNECTMOBILE, 'Content-Type': 'application/x-www-form-urlencoded' } }); // console.log('exchange - response:', response); this.oauth2Token = this.setOauth2TokenExpiresAt(response); // console.log('exchange - oauth2Token:', this.oauth2Token); } setOauth2TokenExpiresAt(token) { const now = luxon_1.DateTime.now(); const expiresAt = now.plus({ seconds: token.expires_in }); const refreshTokenExpiresAt = now.plus({ seconds: token.refresh_token_expires_in }); return { ...token, last_update_date: now.toLocal().toString(), expires_date: expiresAt.toLocal().toString(), expires_at: expiresAt.toSeconds(), refresh_token_expires_at: refreshTokenExpiresAt.toSeconds() }; } } exports.HttpClient = HttpClient; //# sourceMappingURL=HttpClientV1.js.map