tripit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "tripit",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/dvcrn/tripit.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/dvcrn/tripit/issues"
11
+ },
12
+ "homepage": "https://github.com/dvcrn/tripit#readme",
13
+ "type": "module",
14
+ "main": "./src/index.ts",
15
+ "module": "./src/index.ts",
16
+ "types": "./src/types.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./src/types.ts",
20
+ "import": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "bin": {
25
+ "tripit": "./index.ts"
26
+ },
27
+ "files": [
28
+ "index.ts",
29
+ "src"
30
+ ],
31
+ "devDependencies": {
32
+ "@types/bun": "latest",
33
+ "@types/tough-cookie": "^4.0.5"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5.9.3"
37
+ },
38
+ "dependencies": {
39
+ "cheerio": "^1.2.0",
40
+ "commander": "^14.0.3",
41
+ "fetch-cookie": "^3.2.0",
42
+ "node-fetch": "^3.3.2",
43
+ "tough-cookie": "^6.0.0"
44
+ }
45
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,242 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import * as cheerio from "cheerio";
4
+ import fetch from "node-fetch";
5
+ import {
6
+ API_BASE_URL,
7
+ BASE_URL,
8
+ BROWSER_HEADERS,
9
+ CACHE_DIR,
10
+ REDIRECT_URI,
11
+ SCOPES,
12
+ TOKEN_CACHE_FILE,
13
+ } from "./constants";
14
+ import type { CachedToken, TripItConfig } from "./types";
15
+
16
+ export function loadCachedToken(): CachedToken | null {
17
+ try {
18
+ if (fs.existsSync(TOKEN_CACHE_FILE)) {
19
+ return JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, "utf-8"));
20
+ }
21
+ } catch {
22
+ // Ignore corrupt cache
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function cacheToken(tokenResponse: any): void {
28
+ const cached: CachedToken = {
29
+ access_token: tokenResponse.access_token,
30
+ expires_in: tokenResponse.expires_in,
31
+ token_type: tokenResponse.token_type,
32
+ scope: tokenResponse.scope,
33
+ expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
34
+ };
35
+
36
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
37
+ fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cached, null, 2));
38
+ }
39
+
40
+ async function followRedirects(
41
+ fetchFn: typeof fetch,
42
+ url: string,
43
+ ): Promise<{ html: string; formAction: string }> {
44
+ let currentUrl = url;
45
+ for (let i = 0; i < 5; i++) {
46
+ const res = await (fetchFn as any)(currentUrl, {
47
+ headers: BROWSER_HEADERS,
48
+ redirect: "manual",
49
+ });
50
+ const body = await res.text();
51
+
52
+ if (res.status === 302 || res.status === 303) {
53
+ const location = res.headers.get("location");
54
+ if (!location) throw new Error("Redirect without location header");
55
+ currentUrl = new URL(location, currentUrl).href;
56
+ continue;
57
+ }
58
+
59
+ const $ = cheerio.load(body);
60
+ if (
61
+ $('form input[name="username"]').length === 0 ||
62
+ $('form input[name="password"]').length === 0
63
+ ) {
64
+ throw new Error("Login form not found");
65
+ }
66
+
67
+ return { html: body, formAction: currentUrl };
68
+ }
69
+ throw new Error("Too many redirects while getting login form");
70
+ }
71
+
72
+ async function submitLogin(
73
+ fetchFn: typeof fetch,
74
+ config: TripItConfig,
75
+ formHtml: string,
76
+ formAction: string,
77
+ ): Promise<string> {
78
+ const $ = cheerio.load(formHtml);
79
+
80
+ const submitData: Record<string, string> = {};
81
+ $("form input").each((_, el) => {
82
+ const name = $(el).attr("name");
83
+ const value = $(el).attr("value") || "";
84
+ if (name) submitData[name] = value;
85
+ });
86
+ submitData.username = config.username;
87
+ submitData.password = config.password;
88
+
89
+ const formActionUrl = $("form").attr("action");
90
+ if (!formActionUrl) throw new Error("No form action URL found");
91
+
92
+ const finalUrl = new URL(formActionUrl, formAction).href;
93
+
94
+ const res = await (fetchFn as any)(finalUrl, {
95
+ method: "POST",
96
+ headers: {
97
+ ...BROWSER_HEADERS,
98
+ "Content-Type": "application/x-www-form-urlencoded",
99
+ "Sec-Fetch-Site": "same-origin",
100
+ "Sec-Fetch-User": "?1",
101
+ Origin: BASE_URL,
102
+ Referer: formAction,
103
+ },
104
+ body: new URLSearchParams(submitData).toString(),
105
+ redirect: "manual",
106
+ });
107
+
108
+ const responseText = await res.text();
109
+
110
+ if (res.status === 403) {
111
+ throw new Error("Login failed (403)");
112
+ }
113
+
114
+ if (res.status === 302 || res.status === 303) {
115
+ const location = res.headers.get("location");
116
+ if (!location) throw new Error("No redirect location after login");
117
+ return location;
118
+ }
119
+
120
+ if (res.status === 200) {
121
+ const $r = cheerio.load(responseText);
122
+
123
+ const errorMsg = $r(".error-message").text() || $r(".alert-error").text();
124
+ if (errorMsg) throw new Error(`Login failed: ${errorMsg}`);
125
+
126
+ // Check meta refresh
127
+ const meta = $r('meta[http-equiv="refresh"]').attr("content");
128
+ if (meta) {
129
+ const match = meta.match(/URL=(.+)$/);
130
+ if (match?.[1]) return match[1];
131
+ }
132
+
133
+ // Check JS redirect
134
+ const scripts = $r("script").text();
135
+ const redirectMatch = scripts.match(
136
+ /(?:window\.location|window\.location\.href)\s*=\s*["']([^"']+)["']/,
137
+ );
138
+ if (redirectMatch?.[1]) return redirectMatch[1];
139
+
140
+ throw new Error("Could not find redirect URL in login response");
141
+ }
142
+
143
+ throw new Error(`Unexpected login response status: ${res.status}`);
144
+ }
145
+
146
+ async function exchangeCodeForToken(
147
+ config: TripItConfig,
148
+ code: string,
149
+ codeVerifier: string,
150
+ ): Promise<any> {
151
+ const params = new URLSearchParams({
152
+ grant_type: "authorization_code",
153
+ code,
154
+ redirect_uri: REDIRECT_URI,
155
+ client_id: config.clientId,
156
+ code_verifier: codeVerifier,
157
+ });
158
+
159
+ const res = await fetch(`${API_BASE_URL}/oauth2/token`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
+ body: params.toString(),
163
+ });
164
+
165
+ if (!res.ok) {
166
+ const body = await res.text();
167
+ throw new Error(`Token exchange failed (${res.status}): ${body}`);
168
+ }
169
+
170
+ return res.json();
171
+ }
172
+
173
+ export async function authenticate(config: TripItConfig): Promise<string> {
174
+ const cached = loadCachedToken();
175
+ if (cached && cached.expiresAt > Date.now()) {
176
+ return cached.access_token;
177
+ }
178
+
179
+ const fetchCookie = (await import("fetch-cookie")).default;
180
+ const { CookieJar } = await import("tough-cookie");
181
+ const fetchWithCookie = fetchCookie(fetch, new CookieJar());
182
+
183
+ // Establish session
184
+ await fetchWithCookie(`${BASE_URL}/home`, { headers: BROWSER_HEADERS });
185
+
186
+ // PKCE setup
187
+ const codeVerifier = crypto.randomBytes(32).toString("hex");
188
+ const codeChallenge = crypto
189
+ .createHash("sha256")
190
+ .update(codeVerifier)
191
+ .digest()
192
+ .toString("base64")
193
+ .replace(/\+/g, "-")
194
+ .replace(/\//g, "_")
195
+ .replace(/=+$/, "");
196
+ const state = crypto.randomBytes(16).toString("hex");
197
+
198
+ const authUrl =
199
+ `${BASE_URL}/auth/oauth2/authorize?` +
200
+ `client_id=${encodeURIComponent(config.clientId)}` +
201
+ `&response_type=code` +
202
+ `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
203
+ `&scope=${encodeURIComponent(SCOPES)}` +
204
+ `&state=${encodeURIComponent(state)}` +
205
+ `&code_challenge=${encodeURIComponent(codeChallenge)}` +
206
+ `&code_challenge_method=S256` +
207
+ `&response_mode=query` +
208
+ `&action=sign_in`;
209
+
210
+ // Follow redirects to login form
211
+ const { html, formAction } = await followRedirects(fetchWithCookie, authUrl);
212
+
213
+ // Submit login form
214
+ const redirectUrl = await submitLogin(
215
+ fetchWithCookie,
216
+ config,
217
+ html,
218
+ formAction,
219
+ );
220
+
221
+ // Validate state and extract code
222
+ const parsedUrl = new URL(redirectUrl, "http://localhost");
223
+ const returnedState = parsedUrl.searchParams.get("state");
224
+ if (returnedState !== state) {
225
+ throw new Error("OAuth state mismatch");
226
+ }
227
+
228
+ const code = parsedUrl.searchParams.get("code");
229
+ if (!code) {
230
+ throw new Error("Authorization code not found in redirect");
231
+ }
232
+
233
+ // Exchange code for token
234
+ const tokenResponse = await exchangeCodeForToken(config, code, codeVerifier);
235
+ if (!tokenResponse.access_token) {
236
+ throw new Error("No access_token in response");
237
+ }
238
+
239
+ cacheToken(tokenResponse);
240
+
241
+ return tokenResponse.access_token;
242
+ }
@@ -0,0 +1,141 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const CACHE_DIR = path.join(os.homedir(), ".config", "tripit");
5
+ export const TOKEN_CACHE_FILE = path.join(CACHE_DIR, "token.json");
6
+
7
+ export const BASE_URL = "https://www.tripit.com";
8
+ export const API_BASE_URL = "https://api.tripit.com";
9
+ export const REDIRECT_URI = "com.tripit://completeAuthorize";
10
+ export const SCOPES = "offline_access email";
11
+
12
+ export const BROWSER_HEADERS = {
13
+ "User-Agent":
14
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0",
15
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
16
+ "Accept-Language": "en-US,ja;q=0.7,en;q=0.3",
17
+ "Accept-Encoding": "gzip, deflate, br, zstd",
18
+ DNT: "1",
19
+ "Sec-GPC": "1",
20
+ Connection: "keep-alive",
21
+ "Upgrade-Insecure-Requests": "1",
22
+ "Sec-Fetch-Dest": "document",
23
+ "Sec-Fetch-Mode": "navigate",
24
+ "Sec-Fetch-Site": "cross-site",
25
+ Priority: "u=0, i",
26
+ Pragma: "no-cache",
27
+ "Cache-Control": "no-cache",
28
+ };
29
+
30
+ export const TRIP_UPDATE_FIELD_ORDER = [
31
+ "primary_location",
32
+ "TripPurposes",
33
+ "is_private",
34
+ "start_date",
35
+ "display_name",
36
+ "is_expensible",
37
+ "end_date",
38
+ "description",
39
+ ] as const;
40
+
41
+ export const LODGING_FIELD_ORDER = [
42
+ "uuid",
43
+ "trip_uuid",
44
+ "trip_id",
45
+ "is_client_traveler",
46
+ "display_name",
47
+ "Image",
48
+ "supplier_name",
49
+ "supplier_conf_num",
50
+ "booking_rate",
51
+ "is_purchased",
52
+ "notes",
53
+ "total_cost",
54
+ "StartDateTime",
55
+ "EndDateTime",
56
+ "Address",
57
+ ] as const;
58
+
59
+ export const AIR_FIELD_ORDER = [
60
+ "uuid",
61
+ "trip_uuid",
62
+ "trip_id",
63
+ "is_client_traveler",
64
+ "display_name",
65
+ "Image",
66
+ "supplier_name",
67
+ "supplier_conf_num",
68
+ "is_purchased",
69
+ "notes",
70
+ "total_cost",
71
+ "Segment",
72
+ ] as const;
73
+
74
+ export const AIR_SEGMENT_FIELD_ORDER = [
75
+ "uuid",
76
+ "StartDateTime",
77
+ "EndDateTime",
78
+ "start_city_name",
79
+ "start_country_code",
80
+ "end_city_name",
81
+ "end_country_code",
82
+ "marketing_airline",
83
+ "marketing_flight_number",
84
+ "aircraft",
85
+ "service_class",
86
+ ] as const;
87
+
88
+ export const TRANSPORT_FIELD_ORDER = [
89
+ "uuid",
90
+ "trip_uuid",
91
+ "trip_id",
92
+ "is_client_traveler",
93
+ "display_name",
94
+ "Image",
95
+ "is_purchased",
96
+ "is_tripit_booking",
97
+ "has_possible_cancellation",
98
+ "Segment",
99
+ ] as const;
100
+
101
+ export const TRANSPORT_SEGMENT_FIELD_ORDER = [
102
+ "uuid",
103
+ "StartLocationAddress",
104
+ "StartDateTime",
105
+ "EndLocationAddress",
106
+ "EndDateTime",
107
+ "vehicle_description",
108
+ "start_location_name",
109
+ "end_location_name",
110
+ "confirmation_num",
111
+ "carrier_name",
112
+ ] as const;
113
+
114
+ export const ACTIVITY_FIELD_ORDER = [
115
+ "uuid",
116
+ "trip_uuid",
117
+ "trip_id",
118
+ "is_client_traveler",
119
+ "display_name",
120
+ "Image",
121
+ "is_purchased",
122
+ "notes",
123
+ "StartDateTime",
124
+ "EndDateTime",
125
+ "Address",
126
+ "location_name",
127
+ ] as const;
128
+
129
+ export const IMAGE_FIELD_ORDER = [
130
+ "caption",
131
+ "segment_uuid",
132
+ "ImageData",
133
+ ] as const;
134
+
135
+ export const ADDRESS_FIELD_ORDER = [
136
+ "address",
137
+ "city",
138
+ "state",
139
+ "zip",
140
+ "country",
141
+ ] as const;
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { TripIt, TripIt as default } from "./tripit";
2
+ export type * from "./types";