yorck-mcp 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.
@@ -0,0 +1,360 @@
1
+ import puppeteer from "@cloudflare/puppeteer";
2
+ import type { Env } from "../types.ts";
3
+
4
+ const KV_KEY = "yorck:browser-tokens";
5
+
6
+ export interface CapturedTokens {
7
+ // Cognito chain
8
+ idToken: string;
9
+ accessToken: string;
10
+ refreshToken: string;
11
+ // Vista chain (the loyaltySessionToken header value)
12
+ loyaltyAccessToken: string;
13
+ loyaltyRefreshToken: string;
14
+ // Diagnostic — exact headers the React app sends to a validate call
15
+ // (only present if we observed one during the run).
16
+ observedValidateHeaders?: Record<string, string>;
17
+ observedValidateUrl?: string;
18
+ observedValidateBody?: string;
19
+ cookies: string;
20
+ capturedAtMs: number;
21
+ _diag?: {
22
+ loyaltyHits: Array<{ store: string; key: string; value: string }>;
23
+ localKeys: string[];
24
+ sessionKeys: string[];
25
+ networkSummary: string[];
26
+ fetchLog: Array<{
27
+ url: string;
28
+ method: string;
29
+ status: number | null;
30
+ authHeaders: Record<string, string>;
31
+ bodyPreview?: string;
32
+ responsePreview?: string;
33
+ }>;
34
+ finalUrl: string;
35
+ };
36
+ }
37
+
38
+ interface NetworkSnapshot {
39
+ url: string;
40
+ method: string;
41
+ headers: Record<string, string>;
42
+ postData?: string;
43
+ responseStatus?: number;
44
+ responseBody?: string;
45
+ }
46
+
47
+ interface AuthResponse {
48
+ access_token?: string;
49
+ refresh_token?: string;
50
+ }
51
+
52
+ async function readResponseSafely(resp: Awaited<ReturnType<Awaited<ReturnType<typeof puppeteer.launch>>["newPage"]>> extends infer P ? P extends { on(...a: any[]): any } ? Parameters<P["on"]>[1] extends (r: infer R) => any ? R : never : never : never): Promise<string | undefined> {
53
+ try {
54
+ return await (resp as any).text();
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ export async function captureTokensViaBrowser(env: Env, opts: { force?: boolean; observeSession?: { cinemaSlug: string; sessionId: string } } = {}): Promise<CapturedTokens> {
61
+ if (!opts.force) {
62
+ const cached = await env.CACHE.get<CapturedTokens>(KV_KEY, "json");
63
+ // Vista access tokens are 15 min — cache for 10.
64
+ if (cached && Date.now() - cached.capturedAtMs < 10 * 60 * 1000) {
65
+ return cached;
66
+ }
67
+ }
68
+ if (!env.YORCK_EMAIL || !env.YORCK_PASSWORD) {
69
+ throw new Error("YORCK_EMAIL / YORCK_PASSWORD not set");
70
+ }
71
+
72
+ const browser = await puppeteer.launch(env.BROWSER as any);
73
+ try {
74
+ const page = await browser.newPage();
75
+ await page.setUserAgent(
76
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
77
+ );
78
+
79
+ let loyaltyAccessToken = "";
80
+ let loyaltyRefreshToken = "";
81
+ let observedValidateHeaders: Record<string, string> | undefined;
82
+ let observedValidateUrl: string | undefined;
83
+ let observedValidateBody: string | undefined;
84
+ const networkLog: NetworkSnapshot[] = [];
85
+
86
+ // Capture EVERY auth-bearing request from inside the page. We patch both
87
+ // window.fetch and XMLHttpRequest because the AWS Cognito SDK uses XHR.
88
+ await page.evaluateOnNewDocument(`
89
+ (() => {
90
+ const log = (window.__yorckRequestLog = window.__yorckRequestLog || []);
91
+
92
+ const origFetch = window.fetch;
93
+ window.fetch = function(input, init) {
94
+ let url = "", method = (init && init.method) || "GET", headers = {}, body = "";
95
+ try {
96
+ if (typeof input === "string") url = input;
97
+ else if (input && input.url) url = input.url;
98
+ if (init && init.headers) {
99
+ if (init.headers instanceof Headers) {
100
+ init.headers.forEach((v, k) => { headers[k] = v; });
101
+ } else { Object.assign(headers, init.headers); }
102
+ }
103
+ body = (init && typeof init.body === "string") ? init.body : "";
104
+ } catch (e) {}
105
+ const entry = { kind: "fetch", t: Date.now(), url, method, headers, body, status: null, response: null };
106
+ log.push(entry);
107
+ return origFetch.apply(this, arguments).then((res) => {
108
+ entry.status = res.status;
109
+ try {
110
+ res.clone().text().then((txt) => {
111
+ entry.response = txt && txt.length < 4000 ? txt : (txt || "").slice(0, 4000) + "…";
112
+ }).catch(() => {});
113
+ } catch (e) {}
114
+ return res;
115
+ });
116
+ };
117
+
118
+ const OrigXHR = window.XMLHttpRequest;
119
+ const origOpen = OrigXHR.prototype.open;
120
+ const origSend = OrigXHR.prototype.send;
121
+ const origSetHeader = OrigXHR.prototype.setRequestHeader;
122
+ OrigXHR.prototype.open = function(method, url) {
123
+ this.__entry = { kind: "xhr", t: Date.now(), url: String(url), method: String(method).toUpperCase(), headers: {}, body: "", status: null, response: null };
124
+ log.push(this.__entry);
125
+ return origOpen.apply(this, arguments);
126
+ };
127
+ OrigXHR.prototype.setRequestHeader = function(k, v) {
128
+ if (this.__entry) this.__entry.headers[k] = v;
129
+ return origSetHeader.apply(this, arguments);
130
+ };
131
+ OrigXHR.prototype.send = function(body) {
132
+ if (this.__entry) {
133
+ this.__entry.body = typeof body === "string" ? body : "";
134
+ const e = this.__entry;
135
+ this.addEventListener("loadend", () => {
136
+ try {
137
+ e.status = this.status;
138
+ const txt = this.responseText || "";
139
+ e.response = txt.length < 4000 ? txt : txt.slice(0, 4000) + "…";
140
+ } catch (err) {}
141
+ });
142
+ }
143
+ return origSend.apply(this, arguments);
144
+ };
145
+ })();
146
+ `);
147
+
148
+ page.on("request", (req: any) => {
149
+ // Lightweight backup log via CDP for non-fetch requests.
150
+ const url = req.url();
151
+ if (
152
+ url.includes("execute-api") ||
153
+ url.includes("yorck.de") ||
154
+ url.includes("amazonaws.com") ||
155
+ url.includes("cognito")
156
+ ) {
157
+ networkLog.push({ url, method: req.method(), headers: req.headers(), postData: req.postData() });
158
+ }
159
+ });
160
+
161
+ // 1. Go straight to the login page. Observed selectors on yorck.de:
162
+ // email = `input#email` (type=text, name=email)
163
+ // pass = `input#password`
164
+ // submit= `button[type="submit"]`
165
+ await page.goto("https://www.yorck.de/en/login", { waitUntil: "domcontentloaded", timeout: 30000 });
166
+
167
+ // 2. Wait for the form to mount.
168
+ await page.waitForSelector('input#email, input[name="email"]', { timeout: 20000 });
169
+ await page.waitForSelector('input#password, input[name="password"]', { timeout: 20000 });
170
+ const emailSel = (await page.$('input#email')) ? 'input#email' : 'input[name="email"]';
171
+ const passSel = (await page.$('input#password')) ? 'input#password' : 'input[name="password"]';
172
+ await page.type(emailSel, env.YORCK_EMAIL, { delay: 30 });
173
+ await page.type(passSel, env.YORCK_PASSWORD, { delay: 30 });
174
+
175
+ // 4. Submit + wait for navigation in parallel. Login is async; we don't
176
+ // just wait — we wait for the navigation OR a 30s timeout.
177
+ const navPromise = page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => null);
178
+ const submitBtn = await page.$('button[type="submit"]');
179
+ if (submitBtn) {
180
+ await submitBtn.click();
181
+ } else {
182
+ await page.focus(passSel);
183
+ await page.keyboard.press("Enter");
184
+ }
185
+ await navPromise;
186
+
187
+ // 5. Give the React app time to finish any post-login background calls.
188
+ await new Promise((r) => setTimeout(r, 4000));
189
+
190
+ // 6. Optionally drive into a session to capture an actual validate call.
191
+ if (opts.observeSession) {
192
+ const url = `https://www.yorck.de/en/cinema/${opts.observeSession.cinemaSlug}/${opts.observeSession.sessionId}`;
193
+ try {
194
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
195
+ // Give the React app time to fire the validate call when the seat
196
+ // selector mounts.
197
+ await new Promise((r) => setTimeout(r, 5000));
198
+ } catch {
199
+ // not fatal — token capture is the priority
200
+ }
201
+ }
202
+
203
+ // 7. Pull Cognito + Vista tokens out of storage. We evaluate as a string
204
+ // so DOM-context code doesn't fail Worker-side type checking, and so
205
+ // `new Function` (forbidden in the Worker runtime) is not invoked.
206
+ const evalScript = `(() => {
207
+ const prefix = "CognitoIdentityServiceProvider." + ${JSON.stringify(env.COGNITO_CLIENT_ID)} + ".";
208
+ const out = {
209
+ idToken: "", accessToken: "", refreshToken: "", lastUser: "",
210
+ // Sweep both storages for anything that smells like a Vista loyalty token.
211
+ loyaltyHits: [],
212
+ allLocalKeys: [],
213
+ allSessionKeys: []
214
+ };
215
+ for (let i = 0; i < localStorage.length; i++) {
216
+ const k = localStorage.key(i);
217
+ if (!k) continue;
218
+ out.allLocalKeys.push(k);
219
+ if (k.indexOf(prefix) === 0) {
220
+ const v = localStorage.getItem(k) || "";
221
+ if (k.endsWith(".idToken")) out.idToken = v;
222
+ else if (k.endsWith(".accessToken")) out.accessToken = v;
223
+ else if (k.endsWith(".refreshToken")) out.refreshToken = v;
224
+ else if (k.endsWith(".LastAuthUser")) out.lastUser = v;
225
+ }
226
+ if (/loyalty|vista|access[_-]?token|session[_-]?token/i.test(k)) {
227
+ out.loyaltyHits.push({ store: "local", key: k, value: (localStorage.getItem(k) || "").slice(0, 200) });
228
+ }
229
+ }
230
+ for (let i = 0; i < sessionStorage.length; i++) {
231
+ const k = sessionStorage.key(i);
232
+ if (!k) continue;
233
+ out.allSessionKeys.push(k);
234
+ if (/loyalty|vista|access[_-]?token|session[_-]?token/i.test(k)) {
235
+ out.loyaltyHits.push({ store: "session", key: k, value: (sessionStorage.getItem(k) || "").slice(0, 200) });
236
+ }
237
+ }
238
+ return out;
239
+ })()`;
240
+ const cognito = (await page.evaluate(evalScript as any)) as {
241
+ idToken: string;
242
+ accessToken: string;
243
+ refreshToken: string;
244
+ lastUser: string;
245
+ loyaltyHits: Array<{ store: string; key: string; value: string }>;
246
+ allLocalKeys: string[];
247
+ allSessionKeys: string[];
248
+ };
249
+ if (cognito.loyaltyHits?.length && !loyaltyAccessToken) {
250
+ // Try the most likely candidate.
251
+ const hit = cognito.loyaltyHits.find((h) => /access/i.test(h.key)) ?? cognito.loyaltyHits[0];
252
+ if (hit) loyaltyAccessToken = hit.value;
253
+ }
254
+
255
+ // Read in-page fetch log (this is where the React app's actual auth flow lives).
256
+ const fetchLog = (await page.evaluate(`window.__yorckRequestLog || []` as any)) as Array<{
257
+ t: number;
258
+ url: string;
259
+ method: string;
260
+ headers: Record<string, string>;
261
+ body: string;
262
+ status: number | null;
263
+ response: string | null;
264
+ }>;
265
+ // Mine the fetch log for tokens and validate-call signatures.
266
+ for (const entry of fetchLog) {
267
+ if (
268
+ entry.url.includes("/auth/authenticate") ||
269
+ entry.url.includes("/auth/refresh") ||
270
+ entry.url.includes("/auth/login")
271
+ ) {
272
+ if (entry.response) {
273
+ try {
274
+ const j = JSON.parse(entry.response) as AuthResponse;
275
+ if (j.access_token && !loyaltyAccessToken) loyaltyAccessToken = j.access_token;
276
+ if (j.refresh_token && !loyaltyRefreshToken) loyaltyRefreshToken = j.refresh_token;
277
+ } catch {}
278
+ }
279
+ }
280
+ if (entry.url.includes("/validate/membertickets") || entry.url.includes("/order/validate")) {
281
+ observedValidateHeaders = entry.headers;
282
+ observedValidateUrl = entry.url;
283
+ observedValidateBody = entry.body;
284
+ }
285
+ // Also: if any auth-bearing request to execute-api carries a loyaltySessionToken, grab it.
286
+ if (entry.url.includes("execute-api") && entry.headers && !loyaltyAccessToken) {
287
+ const lt = entry.headers["loyaltysessiontoken"] ?? entry.headers["loyaltySessionToken"];
288
+ if (lt && lt.length > 20) loyaltyAccessToken = lt;
289
+ }
290
+ }
291
+
292
+ // We don't fail when localStorage is empty — yorck.de uses HttpOnly
293
+ // cookies, so the auth chain rides on cookies + in-memory React state.
294
+
295
+ const cookies = await page.cookies();
296
+ const tokens: CapturedTokens = {
297
+ idToken: cognito.idToken,
298
+ accessToken: cognito.accessToken,
299
+ refreshToken: cognito.refreshToken,
300
+ loyaltyAccessToken,
301
+ loyaltyRefreshToken,
302
+ observedValidateHeaders,
303
+ observedValidateUrl,
304
+ observedValidateBody,
305
+ cookies: cookies.map((c: any) => `${c.name}=${c.value}`).join("; "),
306
+ capturedAtMs: Date.now(),
307
+ // Diagnostic — surfaced via /v1/browser-tokens
308
+ _diag: {
309
+ loyaltyHits: cognito.loyaltyHits ?? [],
310
+ localKeys: cognito.allLocalKeys ?? [],
311
+ sessionKeys: cognito.allSessionKeys ?? [],
312
+ networkSummary: networkLog
313
+ .map((n) => `${n.method} ${n.url.replace(/^https?:\/\/[^/]+/, "")}`)
314
+ .slice(0, 100),
315
+ fetchLog: fetchLog.map((e) => ({
316
+ url: e.url,
317
+ method: e.method,
318
+ status: e.status,
319
+ // Only log the headers that look auth-bearing — keep payload sane.
320
+ authHeaders: Object.fromEntries(
321
+ Object.entries(e.headers ?? {}).filter(([k]) =>
322
+ /token|auth|cookie|session/i.test(k),
323
+ ),
324
+ ),
325
+ bodyPreview: e.body ? e.body.slice(0, 200) : undefined,
326
+ responsePreview: e.response ? e.response.slice(0, 200) : undefined,
327
+ })),
328
+ finalUrl: page.url(),
329
+ },
330
+ } as CapturedTokens;
331
+
332
+ await env.CACHE.put(KV_KEY, JSON.stringify(tokens), { expirationTtl: 14 * 60 });
333
+ return tokens;
334
+ } finally {
335
+ await browser.close();
336
+ }
337
+ }
338
+
339
+ export async function browserAuthHeaders(env: Env, force = false): Promise<{
340
+ "id-token": string;
341
+ "access-token": string;
342
+ loyaltySessionToken: string;
343
+ connectapitoken: string;
344
+ cookie?: string;
345
+ observed?: { url?: string; headers?: Record<string, string>; body?: string };
346
+ }> {
347
+ const t = await captureTokensViaBrowser(env, { force });
348
+ return {
349
+ "id-token": t.idToken,
350
+ "access-token": t.accessToken,
351
+ loyaltySessionToken: t.loyaltyAccessToken,
352
+ connectapitoken: "",
353
+ cookie: t.cookies || undefined,
354
+ observed: {
355
+ url: t.observedValidateUrl,
356
+ headers: t.observedValidateHeaders,
357
+ body: t.observedValidateBody,
358
+ },
359
+ };
360
+ }
@@ -0,0 +1,220 @@
1
+ import puppeteer from "@cloudflare/puppeteer";
2
+ import type { Env } from "../types.ts";
3
+
4
+ // Drive a real browser login on yorck.de, then run our reserve+validate
5
+ // pipeline FROM INSIDE the page so the React app's fetch wrapper attaches
6
+ // whatever auth headers it normally would. Returns the raw responses so we
7
+ // can compare to what the Worker's direct-fetch path produces.
8
+ export async function runValidateInBrowser(env: Env, args: {
9
+ sessionId: string; // "1009-5990"
10
+ cinemaSlug: string;
11
+ rowLabel: string;
12
+ seatId: string;
13
+ }): Promise<unknown> {
14
+ if (!env.YORCK_EMAIL || !env.YORCK_PASSWORD) throw new Error("YORCK_EMAIL / YORCK_PASSWORD not set");
15
+ if (!env.YORCK_UNLIMITED_CARD) throw new Error("YORCK_UNLIMITED_CARD not set");
16
+
17
+ const [cinemaVistaId, sessionNum] = args.sessionId.split("-");
18
+
19
+ const browser = await puppeteer.launch(env.BROWSER as any);
20
+ try {
21
+ const page = await browser.newPage();
22
+ await page.setUserAgent(
23
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
24
+ );
25
+
26
+ // Capture all fetch + XHR for diagnostics.
27
+ await page.evaluateOnNewDocument(`
28
+ (() => {
29
+ const log = (window.__yorckLog = window.__yorckLog || []);
30
+ const origFetch = window.fetch;
31
+ window.fetch = function(input, init) {
32
+ let url = "", method = (init && init.method) || "GET", headers = {}, body = "";
33
+ try {
34
+ url = typeof input === "string" ? input : (input && input.url) || "";
35
+ if (init && init.headers) {
36
+ if (init.headers instanceof Headers) init.headers.forEach((v, k) => { headers[k] = v; });
37
+ else Object.assign(headers, init.headers);
38
+ }
39
+ body = (init && typeof init.body === "string") ? init.body : "";
40
+ } catch (e) {}
41
+ const entry = { kind: "fetch", t: Date.now(), url, method, headers, body, status: null, response: null };
42
+ log.push(entry);
43
+ return origFetch.apply(this, arguments).then((res) => {
44
+ entry.status = res.status;
45
+ try {
46
+ res.clone().text().then((txt) => {
47
+ entry.response = txt && txt.length < 4000 ? txt : (txt || "").slice(0, 4000) + "…";
48
+ }).catch(() => {});
49
+ } catch (e) {}
50
+ return res;
51
+ });
52
+ };
53
+ const X = window.XMLHttpRequest;
54
+ const oOpen = X.prototype.open, oSend = X.prototype.send, oH = X.prototype.setRequestHeader;
55
+ X.prototype.open = function(m, u) {
56
+ this.__e = { kind: "xhr", t: Date.now(), url: String(u), method: String(m).toUpperCase(), headers: {}, body: "", status: null, response: null };
57
+ log.push(this.__e);
58
+ return oOpen.apply(this, arguments);
59
+ };
60
+ X.prototype.setRequestHeader = function(k, v) { if (this.__e) this.__e.headers[k] = v; return oH.apply(this, arguments); };
61
+ X.prototype.send = function(b) {
62
+ if (this.__e) {
63
+ this.__e.body = typeof b === "string" ? b : "";
64
+ const e = this.__e;
65
+ this.addEventListener("loadend", () => {
66
+ try {
67
+ e.status = this.status;
68
+ const t = this.responseText || "";
69
+ e.response = t.length < 4000 ? t : t.slice(0, 4000) + "…";
70
+ } catch (err) {}
71
+ });
72
+ }
73
+ return oSend.apply(this, arguments);
74
+ };
75
+ })();
76
+ `);
77
+
78
+ // 1. Login.
79
+ await page.goto("https://www.yorck.de/en/login", { waitUntil: "domcontentloaded", timeout: 30000 });
80
+ await page.waitForSelector('input#email', { timeout: 20000 });
81
+ await page.type('input#email', env.YORCK_EMAIL, { delay: 30 });
82
+ await page.type('input#password', env.YORCK_PASSWORD, { delay: 30 });
83
+ const navP = page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => null);
84
+ const btn = await page.$('button[type="submit"]');
85
+ if (btn) await btn.click();
86
+ else { await page.focus('input#password'); await page.keyboard.press("Enter"); }
87
+ await navP;
88
+ await new Promise((r) => setTimeout(r, 3000));
89
+
90
+ // 2. Navigate to the film page and try to click the session button so
91
+ // the React app fires its own validate-membertickets call (with all
92
+ // headers it normally sets via its fetch wrapper).
93
+ await page.goto("https://www.yorck.de/en/films/rose-film", { waitUntil: "domcontentloaded", timeout: 30000 });
94
+ await new Promise((r) => setTimeout(r, 3000));
95
+
96
+ // Find a session-time button and click it to enter the booking flow.
97
+ const sessionTimeClicked = await page.evaluate(`(() => {
98
+ const els = Array.from(document.querySelectorAll('button, a'));
99
+ // Look for an element that mentions "21:30" (Rose tonight) or any
100
+ // anchor whose href contains a session id pattern.
101
+ for (const el of els) {
102
+ const t = (el.textContent || "").trim();
103
+ const href = el.getAttribute && el.getAttribute('href') || '';
104
+ if (t.match(/^[0-9]{1,2}[:.][0-9]{2}/) || /\\d{4}-\\d+/.test(href)) {
105
+ el.click();
106
+ return { matched: t.slice(0, 60), href };
107
+ }
108
+ }
109
+ return null;
110
+ })()` as any);
111
+ await new Promise((r) => setTimeout(r, 5000));
112
+ // Stash the click result in the result for diagnostics.
113
+ (await page.evaluate(`window.__yorckClick = ${JSON.stringify(sessionTimeClicked)}` as any));
114
+
115
+ // 3. From inside the page, run our reserve+validate sequence using
116
+ // window.fetch (which the React app's setup may have wrapped).
117
+ const script = `(async () => {
118
+ const VISTA = "https://uq8lgoj7z2.execute-api.eu-central-1.amazonaws.com/production/api/vista";
119
+ const out = { steps: [] };
120
+ const J = (x) => JSON.stringify(x);
121
+ const post = async (path, body) => {
122
+ const r = await fetch(VISTA + path, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: J(body) });
123
+ const txt = await r.text();
124
+ return { status: r.status, body: txt.length < 3000 ? txt : txt.slice(0, 3000) + "…" };
125
+ };
126
+ const get = async (path) => {
127
+ const r = await fetch(VISTA + path);
128
+ const txt = await r.text();
129
+ return { status: r.status, body: txt.length < 3000 ? txt : txt.slice(0, 3000) + "…" };
130
+ };
131
+
132
+ try {
133
+ // 3a. Create order
134
+ const o = await post("/orders", { cinemaId: ${JSON.stringify(cinemaVistaId)} });
135
+ out.steps.push({ step: "createOrder", ...o });
136
+ const orderJson = JSON.parse(o.body);
137
+ const usid = orderJson.order.userSessionId;
138
+ out.userSessionId = usid;
139
+
140
+ // 3b. Get tickets list
141
+ const t = await get("/RESTData.svc/cinemas/" + ${JSON.stringify(cinemaVistaId)} + "/sessions/" + ${JSON.stringify(sessionNum)} + "/tickets?salesChannelFilter=WWW&userSessionId=" + usid);
142
+ out.steps.push({ step: "tickets", ...t });
143
+ const tj = JSON.parse(t.body);
144
+ const std = tj.Tickets.find(x => x.Description === "Normal (Online)") || tj.Tickets[0];
145
+ out.standardTicket = std;
146
+
147
+ // 3c. Resolve seat
148
+ const sp = await get("/RESTData.svc/cinemas/" + ${JSON.stringify(cinemaVistaId)} + "/sessions/" + ${JSON.stringify(sessionNum)} + "/seat-plan");
149
+ out.steps.push({ step: "seatPlan", status: sp.status, sample: sp.body.slice(0, 200) });
150
+ const spJ = JSON.parse(sp.body);
151
+ const row = spJ.SeatLayoutData.Areas[0].Rows.find(r => (r.PhysicalName || "").trim() === ${JSON.stringify(args.rowLabel)}.trim());
152
+ if (!row) { out.error = "row not found"; return out; }
153
+ const seat = row.Seats.find(s => s.Id === ${JSON.stringify(args.seatId)});
154
+ if (!seat) { out.error = "seat not found"; return out; }
155
+ out.seat = { Position: seat.Position, Status: seat.Status };
156
+
157
+ // 3d. Set standard ticket
158
+ const setBody = {
159
+ tickets: [{
160
+ ticketDetails: {
161
+ ticketTypeCode: std.TicketTypeCode,
162
+ ticketCode: std.TicketCode,
163
+ areaCategoryCode: std.AreaCategoryCode,
164
+ headOfficeGroupingCode: std.HeadOfficeGroupingCode,
165
+ priceInCents: std.PriceInCents,
166
+ },
167
+ seats: [{ areaNumber: seat.Position.AreaNumber, rowIndex: seat.Position.RowIndex, columnIndex: seat.Position.ColumnIndex }],
168
+ }]
169
+ };
170
+ const st = await post("/orders/" + usid + "/sessions/" + ${JSON.stringify(sessionNum)} + "/set-tickets", setBody);
171
+ out.steps.push({ step: "setTickets", ...st });
172
+
173
+ // 3e. Validate Unlimited
174
+ const validateBody = {
175
+ UserSessionId: usid,
176
+ CinemaId: ${JSON.stringify(cinemaVistaId)},
177
+ SessionId: ${parseInt(sessionNum, 10)},
178
+ TicketTypes: [{
179
+ TicketTypeCode: "0183",
180
+ Qty: 1,
181
+ ThirdPartyMemberScheme: { MemberCard: ${JSON.stringify(env.YORCK_UNLIMITED_CARD)} }
182
+ }]
183
+ };
184
+ out.validateBody = validateBody;
185
+ const v = await post("/RESTTicketing.svc/order/validate/membertickets", validateBody);
186
+ out.steps.push({ step: "validateMemberTickets", ...v });
187
+
188
+ // 3f. Cancel cleanup
189
+ try {
190
+ const c = await post("/RESTTicketing.svc/order/cancel", { UserSessionId: usid });
191
+ out.steps.push({ step: "cancel", ...c });
192
+ } catch (e) { out.cancelErr = String(e); }
193
+ } catch (e) {
194
+ out.error = String(e);
195
+ }
196
+ return out;
197
+ })()`;
198
+ const result = await page.evaluate(script as any);
199
+
200
+ // Pull the captured network log so we can see headers actually sent.
201
+ const log = (await page.evaluate(`window.__yorckLog || []` as any)) as Array<any>;
202
+ const apiLog = log
203
+ .filter((e) => /execute-api|amazonaws|yorck/.test(e.url))
204
+ .map((e) => ({
205
+ url: e.url,
206
+ method: e.method,
207
+ status: e.status,
208
+ // Filter to auth-bearing headers only
209
+ headers: Object.fromEntries(Object.entries(e.headers || {}).filter(([k]) => /token|auth|cookie|session/i.test(k)).map(([k, v]) => [k, typeof v === "string" && v.length > 60 ? v.slice(0, 60) + "…" : v])),
210
+ bodyPreview: e.body ? e.body.slice(0, 300) : undefined,
211
+ responsePreview: e.response ? e.response.slice(0, 300) : undefined,
212
+ }));
213
+
214
+ const finalUrl = page.url();
215
+ const clickResult = await page.evaluate(`window.__yorckClick || null` as any);
216
+ return { ok: true, result, apiLog, finalUrl, clickResult };
217
+ } finally {
218
+ await browser.close();
219
+ }
220
+ }
@@ -0,0 +1,46 @@
1
+ import type { Env } from "../types.ts";
2
+
3
+ const KEY = "yorck:buildId";
4
+ const TTL = 600; // 10 min
5
+
6
+ export async function getBuildId(env: Env, force = false): Promise<string> {
7
+ if (!force) {
8
+ const cached = await env.CACHE.get(KEY);
9
+ if (cached) return cached;
10
+ }
11
+ const res = await fetch(env.YORCK_BASE + "/", {
12
+ headers: { "User-Agent": "Mozilla/5.0 (yorck-mcp)" },
13
+ });
14
+ if (!res.ok) throw new Error(`yorck.de homepage HTTP ${res.status}`);
15
+ const html = await res.text();
16
+ const m1 = html.match(/"buildId":"([^"]+)"/);
17
+ const m2 = html.match(/\/_next\/static\/([^/]+)\/_buildManifest/);
18
+ const buildId = m1?.[1] ?? m2?.[1];
19
+ if (!buildId) throw new Error("buildId not found in yorck.de homepage");
20
+ await env.CACHE.put(KEY, buildId, { expirationTtl: TTL });
21
+ return buildId;
22
+ }
23
+
24
+ export async function invalidateBuildId(env: Env): Promise<void> {
25
+ await env.CACHE.delete(KEY);
26
+ }
27
+
28
+ // Wrap a fetch that depends on buildId. Retries once if the data endpoint 404s
29
+ // (likely because Yorck rotated their build).
30
+ export async function withBuildId<T>(
31
+ env: Env,
32
+ fn: (buildId: string) => Promise<T>
33
+ ): Promise<T> {
34
+ const buildId = await getBuildId(env);
35
+ try {
36
+ return await fn(buildId);
37
+ } catch (e) {
38
+ const msg = String(e);
39
+ if (msg.includes("404") || msg.includes("not found")) {
40
+ await invalidateBuildId(env);
41
+ const fresh = await getBuildId(env, true);
42
+ return await fn(fresh);
43
+ }
44
+ throw e;
45
+ }
46
+ }
@@ -0,0 +1,64 @@
1
+ import type { Cinema, Env } from "../types.ts";
2
+ import { withBuildId } from "./buildId.ts";
3
+
4
+ const KEY = "yorck:cinemas";
5
+ const TTL = 1800; // 30 min
6
+
7
+ interface RawCinemasResponse {
8
+ pageProps: {
9
+ cinemas: Array<{
10
+ fields: {
11
+ name: string;
12
+ shortName: string;
13
+ slug: string;
14
+ vistaId: string;
15
+ district: string;
16
+ address: string;
17
+ coordinates?: { lat: number; lon: number };
18
+ numberOfAuditoriums?: number;
19
+ };
20
+ }>;
21
+ };
22
+ }
23
+
24
+ export async function getCinemas(env: Env): Promise<Cinema[]> {
25
+ const cached = await env.CACHE.get(KEY, "json");
26
+ if (cached) return cached as Cinema[];
27
+
28
+ const data = await withBuildId(env, async (buildId) => {
29
+ const url = `${env.YORCK_BASE}/_next/data/${buildId}/en/cinemas.json`;
30
+ const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (yorck-mcp)" } });
31
+ if (!res.ok) throw new Error(`cinemas.json HTTP ${res.status}`);
32
+ return (await res.json()) as RawCinemasResponse;
33
+ });
34
+
35
+ const cinemas: Cinema[] = data.pageProps.cinemas.map((c) => ({
36
+ name: c.fields.name,
37
+ slug: c.fields.slug,
38
+ shortName: c.fields.shortName,
39
+ vistaId: c.fields.vistaId,
40
+ district: c.fields.district,
41
+ address: c.fields.address,
42
+ coordinates: c.fields.coordinates,
43
+ numberOfAuditoriums: c.fields.numberOfAuditoriums,
44
+ }));
45
+
46
+ await env.CACHE.put(KEY, JSON.stringify(cinemas), { expirationTtl: TTL });
47
+ return cinemas;
48
+ }
49
+
50
+ export async function getCinemasMap(env: Env): Promise<Map<string, Cinema>> {
51
+ const cinemas = await getCinemas(env);
52
+ return new Map(cinemas.map((c) => [c.slug, c]));
53
+ }
54
+
55
+ export async function getCinemaBySlug(env: Env, slug: string): Promise<Cinema | undefined> {
56
+ const map = await getCinemasMap(env);
57
+ return map.get(slug);
58
+ }
59
+
60
+ // Lookup cinema by Vista numeric id (e.g. "1009").
61
+ export async function getCinemaByVistaId(env: Env, vistaId: string): Promise<Cinema | undefined> {
62
+ const cinemas = await getCinemas(env);
63
+ return cinemas.find((c) => c.vistaId === vistaId);
64
+ }