@gooin/garmin-connect
Version:
Makes it simple to interface with Garmin Connect to get or set any data point
577 lines • 25.7 kB
JavaScript
"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