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.
- package/README.md +76 -0
- package/bin/yorck.mjs +23 -0
- package/package.json +42 -0
- package/src/cli.ts +508 -0
- package/src/index.ts +408 -0
- package/src/lib/ai-stub.ts +3 -0
- package/src/lib/ics.ts +37 -0
- package/src/lib/jwt.ts +19 -0
- package/src/lib/srp.ts +248 -0
- package/src/lib/tz.ts +75 -0
- package/src/local-mcp.ts +72 -0
- package/src/mcp.ts +30 -0
- package/src/tool-registration.ts +228 -0
- package/src/types.ts +69 -0
- package/src/yorck/auth.ts +156 -0
- package/src/yorck/booking.ts +442 -0
- package/src/yorck/browserAuth.ts +360 -0
- package/src/yorck/browserBook.ts +220 -0
- package/src/yorck/buildId.ts +46 -0
- package/src/yorck/cinemas.ts +64 -0
- package/src/yorck/films.ts +249 -0
- package/src/yorck/seats.ts +169 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# yorck-mcp
|
|
2
|
+
|
|
3
|
+
CLI and MCP tools for Yorck Berlin cinema.
|
|
4
|
+
|
|
5
|
+
- Public search, film lookup, cinemas, seat maps, and ICS calendar files need no account.
|
|
6
|
+
- Booking is local and opt-in. It needs your own Yorck account email, password, and Yorck Unlimited card number.
|
|
7
|
+
- Real booking is never the default. Dry run validates Unlimited pricing and releases the seat hold.
|
|
8
|
+
|
|
9
|
+
## CLI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx yorck-mcp whats-on --when tonight --after 18:00 --cinemas passage,rollberg
|
|
13
|
+
npx yorck-mcp search "devil wears prada"
|
|
14
|
+
npx yorck-mcp seat-map 1007-30456 --out seat-map.svg
|
|
15
|
+
npx yorck-mcp calendar 1007-30456 the-devil-wears-prada-2 --out movie.ics
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Booking
|
|
19
|
+
|
|
20
|
+
Dry run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
YORCK_EMAIL="you@example.com" \
|
|
24
|
+
YORCK_PASSWORD="..." \
|
|
25
|
+
YORCK_UNLIMITED_CARD="..." \
|
|
26
|
+
npx yorck-mcp book-best --q "devil wears prada" --when tonight --after 18:00
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Real booking requires `--commit --yes`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
YORCK_EMAIL="you@example.com" \
|
|
33
|
+
YORCK_PASSWORD="..." \
|
|
34
|
+
YORCK_UNLIMITED_CARD="..." \
|
|
35
|
+
npx yorck-mcp book-best --q "devil wears prada" --when tonight --after 18:00 --commit --yes
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You do not need to provide a separate member ID. The Yorck login response includes it when the account has one, and the booking flow uses it internally.
|
|
39
|
+
|
|
40
|
+
## MCP
|
|
41
|
+
|
|
42
|
+
Read-only remote MCP:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx yorck-mcp mcp-config
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Bookable local stdio MCP:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx yorck-mcp mcp-config --private
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Equivalent config:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"yorck": {
|
|
60
|
+
"command": "npx",
|
|
61
|
+
"args": ["-y", "yorck-mcp", "mcp-stdio"],
|
|
62
|
+
"env": {
|
|
63
|
+
"YORCK_EMAIL": "you@example.com",
|
|
64
|
+
"YORCK_PASSWORD": "...",
|
|
65
|
+
"YORCK_UNLIMITED_CARD": "..."
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Hosted public MCP endpoint:
|
|
73
|
+
|
|
74
|
+
```txt
|
|
75
|
+
https://yorck-mcp.isiklimahir.workers.dev/public/mcp
|
|
76
|
+
```
|
package/bin/yorck.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cli = join(here, "..", "src", "cli.ts");
|
|
10
|
+
let tsxCli;
|
|
11
|
+
try {
|
|
12
|
+
tsxCli = join(dirname(require.resolve("tsx/package.json")), "dist", "cli.mjs");
|
|
13
|
+
} catch {
|
|
14
|
+
console.error("yorck: missing dependency 'tsx'. Run npm install in the yorck package.");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const result = spawnSync(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
19
|
+
if (result.error) {
|
|
20
|
+
console.error(result.error.message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
process.exit(result.status ?? 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yorck-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"deploy": "wrangler deploy",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"tail": "wrangler tail",
|
|
11
|
+
"cli": "tsx src/cli.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@cloudflare/puppeteer": "^1.1.0",
|
|
15
|
+
"@modelcontextprotocol/sdk": "1.23.0",
|
|
16
|
+
"agents": "^0.2.5",
|
|
17
|
+
"hono": "^4.10.4",
|
|
18
|
+
"zod": "^3.24.1",
|
|
19
|
+
"tsx": "^4.21.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@cloudflare/workers-types": "^4.20251013.0",
|
|
23
|
+
"typescript": "^5.7.3",
|
|
24
|
+
"wrangler": "^4.68.0",
|
|
25
|
+
"@types/node": "^24.0.0"
|
|
26
|
+
},
|
|
27
|
+
"overrides": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "1.23.0"
|
|
29
|
+
},
|
|
30
|
+
"resolutions": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "1.23.0"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"yorck": "bin/yorck.mjs",
|
|
35
|
+
"yorck-mcp": "bin/yorck.mjs"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"bin",
|
|
39
|
+
"src",
|
|
40
|
+
"README.md"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import type { Env, Showtime } from "./types.ts";
|
|
6
|
+
import { findFilmsByQuery, getComingSoon, searchShowtimes, type FilmFilters } from "./yorck/films.ts";
|
|
7
|
+
import { getCinemas, getCinemaBySlug } from "./yorck/cinemas.ts";
|
|
8
|
+
import { findSeatByRowAndId, getSeatPlan, renderSeatPlanSvg } from "./yorck/seats.ts";
|
|
9
|
+
import { cancelOrder, commitOrder, reserveUnlimited } from "./yorck/booking.ts";
|
|
10
|
+
import { showtimeToIcs } from "./lib/ics.ts";
|
|
11
|
+
import { whoAmI } from "./yorck/auth.ts";
|
|
12
|
+
|
|
13
|
+
const PUBLIC_MCP_URL = "https://yorck-mcp.isiklimahir.workers.dev/public/mcp";
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
PREFERRED_CINEMAS: "babylon-kreuzberg,delphi-filmpalast,delphi-lux,filmtheater-am-friedrichshain,kant-kino,kino-international,neues-off,odeon,passage,rollberg,yorck",
|
|
17
|
+
DEFAULT_FORMATS: "OmeU,OV,OmU",
|
|
18
|
+
COGNITO_USER_POOL: "eu-central-1_TIusy2VuG",
|
|
19
|
+
COGNITO_CLIENT_ID: "4m9hc0qk59mvcb4hfd6lep1262",
|
|
20
|
+
COGNITO_REGION: "eu-central-1",
|
|
21
|
+
VISTA_BASE: "https://uq8lgoj7z2.execute-api.eu-central-1.amazonaws.com/production/api/vista",
|
|
22
|
+
YORCK_AUTH_BASE: "https://rbfmu7cs19.execute-api.eu-central-1.amazonaws.com/production",
|
|
23
|
+
YORCK_BASE: "https://www.yorck.de",
|
|
24
|
+
PUBLIC_BASE_URL: "https://yorck-mcp.isiklimahir.workers.dev",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ParsedArgs = { _: string[]; [key: string]: string | boolean | string[] };
|
|
28
|
+
|
|
29
|
+
type KvRecord = { value: string; expiresAt?: number };
|
|
30
|
+
|
|
31
|
+
class MemoryKv {
|
|
32
|
+
private store = new Map<string, KvRecord>();
|
|
33
|
+
|
|
34
|
+
async get(key: string, type?: "text" | "json" | "arrayBuffer" | "stream") {
|
|
35
|
+
const record = this.store.get(key);
|
|
36
|
+
if (!record) return null;
|
|
37
|
+
if (record.expiresAt && Date.now() > record.expiresAt) {
|
|
38
|
+
this.store.delete(key);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (type === "json") return JSON.parse(record.value);
|
|
42
|
+
if (type === "arrayBuffer") return new TextEncoder().encode(record.value).buffer;
|
|
43
|
+
return record.value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async put(key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: { expirationTtl?: number }) {
|
|
47
|
+
if (typeof value !== "string") throw new Error("CLI MemoryKv only supports string values");
|
|
48
|
+
this.store.set(key, {
|
|
49
|
+
value,
|
|
50
|
+
expiresAt: options?.expirationTtl ? Date.now() + options.expirationTtl * 1000 : undefined,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function envFromProcess(): Env {
|
|
56
|
+
return {
|
|
57
|
+
CACHE: new MemoryKv() as unknown as KVNamespace,
|
|
58
|
+
MCP_OBJECT: undefined as unknown as DurableObjectNamespace,
|
|
59
|
+
PUBLIC_MCP_OBJECT: undefined as unknown as DurableObjectNamespace,
|
|
60
|
+
BROWSER: undefined as unknown as Fetcher,
|
|
61
|
+
PREFERRED_CINEMAS: process.env.YORCK_PREFERRED_CINEMAS || DEFAULTS.PREFERRED_CINEMAS,
|
|
62
|
+
DEFAULT_FORMATS: process.env.YORCK_DEFAULT_FORMATS || DEFAULTS.DEFAULT_FORMATS,
|
|
63
|
+
COGNITO_USER_POOL: process.env.COGNITO_USER_POOL || DEFAULTS.COGNITO_USER_POOL,
|
|
64
|
+
COGNITO_CLIENT_ID: process.env.COGNITO_CLIENT_ID || DEFAULTS.COGNITO_CLIENT_ID,
|
|
65
|
+
COGNITO_REGION: process.env.COGNITO_REGION || DEFAULTS.COGNITO_REGION,
|
|
66
|
+
VISTA_BASE: process.env.VISTA_BASE || DEFAULTS.VISTA_BASE,
|
|
67
|
+
YORCK_AUTH_BASE: process.env.YORCK_AUTH_BASE || DEFAULTS.YORCK_AUTH_BASE,
|
|
68
|
+
YORCK_BASE: process.env.YORCK_BASE || DEFAULTS.YORCK_BASE,
|
|
69
|
+
PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL || DEFAULTS.PUBLIC_BASE_URL,
|
|
70
|
+
YORCK_EMAIL: process.env.YORCK_EMAIL,
|
|
71
|
+
YORCK_PASSWORD: process.env.YORCK_PASSWORD,
|
|
72
|
+
YORCK_UNLIMITED_CARD: process.env.YORCK_UNLIMITED_CARD,
|
|
73
|
+
YORCK_MCP_AUTH_TOKEN: process.env.YORCK_MCP_AUTH_TOKEN,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseArgv(argv: string[]): ParsedArgs {
|
|
78
|
+
const out: ParsedArgs = { _: [] };
|
|
79
|
+
for (let i = 0; i < argv.length; i++) {
|
|
80
|
+
const arg = argv[i]!;
|
|
81
|
+
if (!arg.startsWith("-")) {
|
|
82
|
+
out._.push(arg);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg === "--") {
|
|
86
|
+
out._.push(...argv.slice(i + 1));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const normalized = arg.replace(/^--?/, "");
|
|
90
|
+
const [rawKey, inline] = normalized.split("=", 2);
|
|
91
|
+
const key = rawKey.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
92
|
+
const next = argv[i + 1];
|
|
93
|
+
let value: string | boolean = true;
|
|
94
|
+
if (inline !== undefined) value = inline;
|
|
95
|
+
else if (next && !next.startsWith("-")) {
|
|
96
|
+
value = next;
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
if (out[key] === undefined) out[key] = value;
|
|
100
|
+
else if (Array.isArray(out[key])) (out[key] as string[]).push(String(value));
|
|
101
|
+
else out[key] = [String(out[key]), String(value)];
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function str(args: ParsedArgs, ...names: string[]): string | undefined {
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const v = args[name];
|
|
109
|
+
if (typeof v === "string") return v;
|
|
110
|
+
if (typeof v === "boolean" && v) return "true";
|
|
111
|
+
if (Array.isArray(v)) return v[v.length - 1];
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function bool(args: ParsedArgs, name: string, defaultValue = false): boolean {
|
|
117
|
+
const v = args[name];
|
|
118
|
+
if (v === undefined) return defaultValue;
|
|
119
|
+
if (typeof v === "boolean") return v;
|
|
120
|
+
return !["false", "0", "no", "off"].includes(String(v).toLowerCase());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function csv(args: ParsedArgs, name: string): string[] | undefined {
|
|
124
|
+
const v = args[name];
|
|
125
|
+
const values = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
126
|
+
const parts = values.flatMap((x) => x.split(",").map((s) => s.trim()).filter(Boolean));
|
|
127
|
+
return parts.length ? parts : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function intOpt(args: ParsedArgs, name: string): number | undefined {
|
|
131
|
+
const v = str(args, name);
|
|
132
|
+
if (!v) return undefined;
|
|
133
|
+
const n = Number.parseInt(v, 10);
|
|
134
|
+
return Number.isFinite(n) ? n : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function filters(args: ParsedArgs): FilmFilters {
|
|
138
|
+
return {
|
|
139
|
+
when: str(args, "when") as FilmFilters["when"],
|
|
140
|
+
date: str(args, "date"),
|
|
141
|
+
cinemas: csv(args, "cinemas"),
|
|
142
|
+
formats: csv(args, "formats") ?? (bool(args, "includeGermanDub") ? ["OmeU", "OV", "OmU", "DF"] : undefined),
|
|
143
|
+
preferEnglish: args.preferEnglish === undefined ? undefined : bool(args, "preferEnglish"),
|
|
144
|
+
genres: csv(args, "genres"),
|
|
145
|
+
fskMax: intOpt(args, "fskMax"),
|
|
146
|
+
runtimeMax: intOpt(args, "runtimeMax"),
|
|
147
|
+
after: str(args, "after"),
|
|
148
|
+
before: str(args, "before"),
|
|
149
|
+
yorckPick: args.yorckPick === undefined ? undefined : bool(args, "yorckPick"),
|
|
150
|
+
district: csv(args, "district"),
|
|
151
|
+
query: str(args, "query", "q") ?? (args._.join(" ") || undefined),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function printJson(value: unknown) {
|
|
156
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function shortTime(iso: string): string {
|
|
160
|
+
const m = iso.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/);
|
|
161
|
+
return m ? `${m[1]} ${m[2]}` : iso;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printShowtimes(showtimes: Showtime[], limit = 20) {
|
|
165
|
+
if (!showtimes.length) {
|
|
166
|
+
console.log("No matching showtimes.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const rows = showtimes.slice(0, limit).map((s, i) => ({
|
|
170
|
+
"#": i + 1,
|
|
171
|
+
time: shortTime(s.start),
|
|
172
|
+
film: s.film,
|
|
173
|
+
cinema: s.cinemaSlug,
|
|
174
|
+
format: s.format,
|
|
175
|
+
session: s.sessionId,
|
|
176
|
+
}));
|
|
177
|
+
console.table(rows);
|
|
178
|
+
if (showtimes.length > limit) console.log(`Showing ${limit}/${showtimes.length}. Use --json for all results.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function ask(question: string, hidden = false): Promise<string> {
|
|
182
|
+
if (!hidden || !process.stdin.isTTY) {
|
|
183
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
184
|
+
const answer = await rl.question(question);
|
|
185
|
+
rl.close();
|
|
186
|
+
return answer.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
process.stdout.write(question);
|
|
190
|
+
process.stdin.setRawMode?.(true);
|
|
191
|
+
process.stdin.resume();
|
|
192
|
+
process.stdin.setEncoding("utf8");
|
|
193
|
+
let answer = "";
|
|
194
|
+
return await new Promise<string>((resolve, reject) => {
|
|
195
|
+
const onData = (chunk: string) => {
|
|
196
|
+
for (const ch of chunk) {
|
|
197
|
+
if (ch === "\u0003") {
|
|
198
|
+
cleanup();
|
|
199
|
+
reject(new Error("cancelled"));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "\r" || ch === "\n") {
|
|
203
|
+
process.stdout.write("\n");
|
|
204
|
+
cleanup();
|
|
205
|
+
resolve(answer.trim());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (ch === "\u007f") {
|
|
209
|
+
answer = answer.slice(0, -1);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
answer += ch;
|
|
213
|
+
process.stdout.write("*");
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const cleanup = () => {
|
|
217
|
+
process.stdin.off("data", onData);
|
|
218
|
+
process.stdin.setRawMode?.(false);
|
|
219
|
+
process.stdin.pause();
|
|
220
|
+
};
|
|
221
|
+
process.stdin.on("data", onData);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function ensureCredentials(env: Env, args: ParsedArgs): Promise<Env> {
|
|
226
|
+
env.YORCK_EMAIL = str(args, "email") || env.YORCK_EMAIL || await ask("Yorck email: ");
|
|
227
|
+
env.YORCK_PASSWORD = str(args, "password") || env.YORCK_PASSWORD || await ask("Yorck password: ", true);
|
|
228
|
+
env.YORCK_UNLIMITED_CARD = str(args, "card", "unlimitedCard") || env.YORCK_UNLIMITED_CARD || await ask("Yorck Unlimited card number: ");
|
|
229
|
+
return env;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function seatRowsFromPlan(plan: any) {
|
|
233
|
+
return (plan.SeatLayoutData?.Areas?.[0]?.Rows ?? []).map((row: any) => ({
|
|
234
|
+
physicalName: row.PhysicalName,
|
|
235
|
+
rowIndex: row.RowIndexZeroBased,
|
|
236
|
+
availableIds: (row.Seats ?? []).filter((seat: any) => seat.Status === 0).map((seat: any) => seat.Id),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function rankShowtimes(showtimes: Showtime[]) {
|
|
241
|
+
const priority: Record<string, number> = { OmeU: 4, OV: 3, OmU: 2, DF: 1 };
|
|
242
|
+
return [...showtimes].sort((a, b) => {
|
|
243
|
+
const fp = (priority[b.format] ?? 0) - (priority[a.format] ?? 0);
|
|
244
|
+
if (fp !== 0) return fp;
|
|
245
|
+
return a.start.localeCompare(b.start);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function pickSeat(rows: Array<{ physicalName: string; rowIndex: number; availableIds: Array<string | number> }>) {
|
|
250
|
+
const availableRows = rows.filter((row) => row.availableIds?.length);
|
|
251
|
+
if (!availableRows.length) return null;
|
|
252
|
+
const minIndex = Math.min(...availableRows.map((r) => Number(r.rowIndex) || 0));
|
|
253
|
+
const maxIndex = Math.max(...availableRows.map((r) => Number(r.rowIndex) || 0));
|
|
254
|
+
const idealRow = minIndex + (maxIndex - minIndex) * 0.45;
|
|
255
|
+
const row = [...availableRows].sort((a, b) => {
|
|
256
|
+
const aDist = Math.abs((Number(a.rowIndex) || 0) - idealRow);
|
|
257
|
+
const bDist = Math.abs((Number(b.rowIndex) || 0) - idealRow);
|
|
258
|
+
if (aDist !== bDist) return aDist - bDist;
|
|
259
|
+
return (b.availableIds.length || 0) - (a.availableIds.length || 0);
|
|
260
|
+
})[0]!;
|
|
261
|
+
const seats = [...row.availableIds].sort((a, b) => Number(a) - Number(b));
|
|
262
|
+
return { rowLabel: String(row.physicalName), seatId: String(seats[Math.floor((seats.length - 1) / 2)]), availableSeatsInRow: seats.map(String) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function seatPlanForSession(env: Env, sessionId: string) {
|
|
266
|
+
const [cinemaVistaId, sessionNum] = sessionId.split("-");
|
|
267
|
+
if (!cinemaVistaId || !sessionNum) throw new Error("sessionId must look like 1009-5990");
|
|
268
|
+
const plan = await getSeatPlan(env, cinemaVistaId, sessionNum);
|
|
269
|
+
const cinema = (await getCinemas(env)).find((c) => c.vistaId === cinemaVistaId);
|
|
270
|
+
return { plan, cinema, cinemaVistaId, sessionNum, rows: seatRowsFromPlan(plan) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function bookSpecific(env: Env, args: ParsedArgs, selection: { sessionId: string; cinemaSlug: string; rowLabel: string; seatId: string; showtime?: Showtime }) {
|
|
274
|
+
await ensureCredentials(env, args);
|
|
275
|
+
const realBooking = bool(args, "commit") || bool(args, "confirm") || bool(args, "book");
|
|
276
|
+
if (realBooking && !bool(args, "yes")) {
|
|
277
|
+
const answer = await ask(`This will actually book ${selection.sessionId} row ${selection.rowLabel} seat ${selection.seatId}. Type BOOK to continue: `);
|
|
278
|
+
if (answer !== "BOOK") throw new Error("booking cancelled");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const reservation = await reserveUnlimited(env, selection);
|
|
282
|
+
const summary = {
|
|
283
|
+
approved: reservation.approved,
|
|
284
|
+
ticketTypeCode: reservation.ticketTypeCode,
|
|
285
|
+
orderTotal: reservation.order.orderTotalValueInCents / 100,
|
|
286
|
+
expiresAt: reservation.expiresAt,
|
|
287
|
+
seat: { row: selection.rowLabel, seat: selection.seatId },
|
|
288
|
+
film: reservation.order.sessions[0]?.filmTitle ?? selection.showtime?.film,
|
|
289
|
+
start: reservation.order.sessions[0]?.startTime ?? selection.showtime?.start,
|
|
290
|
+
sessionId: selection.sessionId,
|
|
291
|
+
cinema: selection.cinemaSlug,
|
|
292
|
+
userSessionId: reservation.order.userSessionId,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (!realBooking) {
|
|
296
|
+
await cancelOrder(env, reservation.order.userSessionId).catch(() => undefined);
|
|
297
|
+
return { ok: true, dryRun: true, note: "Unlimited validated, hold released. Add --commit --yes to actually book.", ...summary };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!reservation.approved) {
|
|
301
|
+
await cancelOrder(env, reservation.order.userSessionId).catch(() => undefined);
|
|
302
|
+
throw new Error("Unlimited validation did not approve this ticket");
|
|
303
|
+
}
|
|
304
|
+
const commit = await commitOrder(env, reservation.order.userSessionId);
|
|
305
|
+
return { ok: true, dryRun: false, ...summary, commit };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function commandWhatsOn(env: Env, args: ParsedArgs) {
|
|
309
|
+
const out = await searchShowtimes(env, filters(args));
|
|
310
|
+
if (bool(args, "json")) printJson({ count: out.length, showtimes: out });
|
|
311
|
+
else printShowtimes(out, intOpt(args, "limit") ?? 20);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function commandSearch(env: Env, args: ParsedArgs) {
|
|
315
|
+
const query = str(args, "query", "q") ?? args._.join(" ");
|
|
316
|
+
if (!query) throw new Error("usage: yorck search <query>");
|
|
317
|
+
printJson(await findFilmsByQuery(env, query, intOpt(args, "limit") ?? 8));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function commandShowtimes(env: Env, args: ParsedArgs) {
|
|
321
|
+
const slug = args._[0] || str(args, "slug");
|
|
322
|
+
if (!slug) throw new Error("usage: yorck showtimes <film-slug>");
|
|
323
|
+
const out = (await searchShowtimes(env, { ...filters(args), query: undefined })).filter((s) => s.slug === slug);
|
|
324
|
+
if (bool(args, "json")) printJson({ count: out.length, showtimes: out });
|
|
325
|
+
else printShowtimes(out, intOpt(args, "limit") ?? 20);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function commandSeatMap(env: Env, args: ParsedArgs) {
|
|
329
|
+
const sessionId = args._[0] || str(args, "sessionId");
|
|
330
|
+
if (!sessionId) throw new Error("usage: yorck seat-map <session-id> [--out seat-map.svg]");
|
|
331
|
+
const { plan, cinema, rows } = await seatPlanForSession(env, sessionId);
|
|
332
|
+
const svg = renderSeatPlanSvg(plan, { title: cinema?.name ?? "Yorck", subtitle: `Session ${sessionId}` });
|
|
333
|
+
const out = str(args, "out");
|
|
334
|
+
if (out) {
|
|
335
|
+
await writeFile(out, svg, "utf8");
|
|
336
|
+
console.log(`Wrote ${out}`);
|
|
337
|
+
}
|
|
338
|
+
printJson({ sessionId, cinema: cinema?.name, rows, svg: out ? undefined : svg });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function commandCalendar(env: Env, args: ParsedArgs) {
|
|
342
|
+
const sessionId = args._[0] || str(args, "sessionId");
|
|
343
|
+
const slug = args._[1] || str(args, "slug");
|
|
344
|
+
if (!sessionId || !slug) throw new Error("usage: yorck calendar <session-id> <film-slug> [--out event.ics]");
|
|
345
|
+
const all = await searchShowtimes(env, { cinemas: undefined, formats: ["OmeU", "OV", "OmU", "DF"] });
|
|
346
|
+
const showtime = all.find((s) => s.sessionId === sessionId && s.slug === slug);
|
|
347
|
+
if (!showtime) throw new Error("session not found in current showtimes");
|
|
348
|
+
const cinema = (await getCinemas(env)).find((c) => c.slug === showtime.cinemaSlug);
|
|
349
|
+
const ics = showtimeToIcs(showtime, cinema?.address);
|
|
350
|
+
const out = str(args, "out");
|
|
351
|
+
if (out) {
|
|
352
|
+
await writeFile(out, ics, "utf8");
|
|
353
|
+
console.log(`Wrote ${out}`);
|
|
354
|
+
} else {
|
|
355
|
+
process.stdout.write(ics);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function commandBook(env: Env, args: ParsedArgs) {
|
|
360
|
+
const sessionId = args._[0] || str(args, "sessionId");
|
|
361
|
+
const cinemaSlug = str(args, "cinema", "cinemaSlug");
|
|
362
|
+
const rowLabel = str(args, "row", "rowLabel");
|
|
363
|
+
const seatId = str(args, "seat", "seatId");
|
|
364
|
+
if (!sessionId || !cinemaSlug || !rowLabel || !seatId) {
|
|
365
|
+
throw new Error("usage: yorck book <session-id> --cinema passage --row 8 --seat 12 [--commit --yes]");
|
|
366
|
+
}
|
|
367
|
+
printJson(await bookSpecific(env, args, { sessionId, cinemaSlug, rowLabel, seatId }));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function commandBookBest(env: Env, args: ParsedArgs) {
|
|
371
|
+
const search = await searchShowtimes(env, filters(args));
|
|
372
|
+
const candidates = rankShowtimes(search).slice(0, intOpt(args, "candidates") ?? 8);
|
|
373
|
+
for (const showtime of candidates) {
|
|
374
|
+
try {
|
|
375
|
+
const { rows } = await seatPlanForSession(env, showtime.sessionId);
|
|
376
|
+
const seat = pickSeat(rows);
|
|
377
|
+
if (!seat) continue;
|
|
378
|
+
const booking = await bookSpecific(env, args, {
|
|
379
|
+
sessionId: showtime.sessionId,
|
|
380
|
+
cinemaSlug: showtime.cinemaSlug,
|
|
381
|
+
rowLabel: seat.rowLabel,
|
|
382
|
+
seatId: seat.seatId,
|
|
383
|
+
showtime,
|
|
384
|
+
});
|
|
385
|
+
printJson({ selected: { ...showtime, seat: { row: seat.rowLabel, seat: seat.seatId } }, booking });
|
|
386
|
+
return;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (bool(args, "verbose")) console.error(`Skipping ${showtime.sessionId}: ${String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
throw new Error("No bookable showtime with available seats found");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function commandMe(env: Env, args: ParsedArgs) {
|
|
395
|
+
await ensureCredentials(env, args);
|
|
396
|
+
printJson(await whoAmI(env));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function commandMcpConfig(args: ParsedArgs) {
|
|
400
|
+
const privateUrl = str(args, "url") || process.env.YORCK_MCP_URL || "https://yorck-mcp.isiklimahir.workers.dev/mcp";
|
|
401
|
+
if (bool(args, "remotePrivate")) {
|
|
402
|
+
printJson({
|
|
403
|
+
mcpServers: {
|
|
404
|
+
yorck: {
|
|
405
|
+
command: "npx",
|
|
406
|
+
args: ["-y", "mcp-remote", privateUrl],
|
|
407
|
+
env: { YORCK_MCP_AUTH_TOKEN: "<your bearer token>" },
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (bool(args, "private") || bool(args, "local")) {
|
|
414
|
+
printJson({
|
|
415
|
+
mcpServers: {
|
|
416
|
+
yorck: {
|
|
417
|
+
command: "npx",
|
|
418
|
+
args: ["-y", "yorck-mcp", "mcp-stdio"],
|
|
419
|
+
env: {
|
|
420
|
+
YORCK_EMAIL: "<your Yorck email>",
|
|
421
|
+
YORCK_PASSWORD: "<your Yorck password>",
|
|
422
|
+
YORCK_UNLIMITED_CARD: "<your Yorck Unlimited card number>",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
printJson({
|
|
430
|
+
mcpServers: {
|
|
431
|
+
yorck: {
|
|
432
|
+
command: "npx",
|
|
433
|
+
args: ["-y", "mcp-remote", PUBLIC_MCP_URL],
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function help() {
|
|
440
|
+
console.log(`yorck, CLI + MCP helper for Yorck Berlin cinema
|
|
441
|
+
|
|
442
|
+
Usage:
|
|
443
|
+
yorck whats-on [--when tonight] [--after 18:00] [--cinemas passage,rollberg] [--q "devil"]
|
|
444
|
+
yorck search <film query>
|
|
445
|
+
yorck showtimes <film-slug> [--date YYYY-MM-DD]
|
|
446
|
+
yorck cinemas
|
|
447
|
+
yorck coming-soon
|
|
448
|
+
yorck seat-map <session-id> [--out seat-map.svg]
|
|
449
|
+
yorck calendar <session-id> <film-slug> [--out event.ics]
|
|
450
|
+
yorck me [--email you@example.com --password ...]
|
|
451
|
+
yorck book <session-id> --cinema <slug> --row <row> --seat <seat> [--commit --yes]
|
|
452
|
+
yorck book-best --q <film> --when tonight --after 18:00 [--commit --yes]
|
|
453
|
+
yorck mcp-stdio
|
|
454
|
+
yorck mcp-config [--private]
|
|
455
|
+
|
|
456
|
+
Public commands need no account. Booking commands need Yorck credentials and an Unlimited card number.
|
|
457
|
+
Use env vars instead of flags for secrets: YORCK_EMAIL, YORCK_PASSWORD, YORCK_UNLIMITED_CARD.
|
|
458
|
+
Booking is dry-run by default and releases the hold. Real booking requires --commit and either --yes or typing BOOK.
|
|
459
|
+
`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function main() {
|
|
463
|
+
const argv = process.argv.slice(2);
|
|
464
|
+
const command = argv.shift();
|
|
465
|
+
const args = parseArgv(argv);
|
|
466
|
+
const env = envFromProcess();
|
|
467
|
+
|
|
468
|
+
if (!command || command === "help" || command === "--help" || command === "-h") return help();
|
|
469
|
+
switch (command) {
|
|
470
|
+
case "whats-on":
|
|
471
|
+
case "films":
|
|
472
|
+
return commandWhatsOn(env, args);
|
|
473
|
+
case "search":
|
|
474
|
+
case "find-film":
|
|
475
|
+
return commandSearch(env, args);
|
|
476
|
+
case "showtimes":
|
|
477
|
+
return commandShowtimes(env, args);
|
|
478
|
+
case "cinemas":
|
|
479
|
+
return printJson(await getCinemas(env));
|
|
480
|
+
case "coming-soon":
|
|
481
|
+
return printJson(await getComingSoon(env));
|
|
482
|
+
case "seat-map":
|
|
483
|
+
return commandSeatMap(env, args);
|
|
484
|
+
case "calendar":
|
|
485
|
+
case "ics":
|
|
486
|
+
return commandCalendar(env, args);
|
|
487
|
+
case "me":
|
|
488
|
+
return commandMe(env, args);
|
|
489
|
+
case "book":
|
|
490
|
+
return commandBook(env, args);
|
|
491
|
+
case "book-best":
|
|
492
|
+
return commandBookBest(env, args);
|
|
493
|
+
case "mcp-config":
|
|
494
|
+
return commandMcpConfig(args);
|
|
495
|
+
case "mcp-stdio":
|
|
496
|
+
case "serve-mcp": {
|
|
497
|
+
const { runLocalMcp } = await import("./local-mcp.ts");
|
|
498
|
+
return runLocalMcp();
|
|
499
|
+
}
|
|
500
|
+
default:
|
|
501
|
+
throw new Error(`Unknown command: ${command}. Run 'yorck help'.`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
main().catch((error) => {
|
|
506
|
+
console.error(`yorck: ${error instanceof Error ? error.message : String(error)}`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
});
|