@agentrhq/webcmd 0.2.1 → 0.2.3
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 +39 -24
- package/cli-manifest.json +2308 -650
- package/clis/_shared/site-auth.js +6 -1
- package/clis/bigbasket/add-to-cart.js +82 -0
- package/clis/bigbasket/bigbasket.test.js +255 -0
- package/clis/bigbasket/cart.js +81 -0
- package/clis/bigbasket/category.js +30 -0
- package/clis/bigbasket/checkout.js +71 -0
- package/clis/bigbasket/location.js +30 -0
- package/clis/bigbasket/product.js +79 -0
- package/clis/bigbasket/search.js +30 -0
- package/clis/bigbasket/utils.js +207 -0
- package/clis/blinkit/add-to-cart.js +123 -0
- package/clis/blinkit/auth.js +99 -0
- package/clis/blinkit/blinkit.test.js +168 -0
- package/clis/blinkit/cart.js +34 -0
- package/clis/blinkit/checkout.js +32 -0
- package/clis/blinkit/location.js +29 -0
- package/clis/blinkit/place-order.js +78 -0
- package/clis/blinkit/product.js +63 -0
- package/clis/blinkit/search.js +89 -0
- package/clis/blinkit/utils.js +223 -0
- package/clis/district/_lib.js +566 -0
- package/clis/district/auth.js +49 -0
- package/clis/district/checkout.js +348 -0
- package/clis/district/listings.js +158 -0
- package/clis/district/locations.js +211 -0
- package/clis/district/search.js +218 -0
- package/clis/district/seats.js +233 -0
- package/clis/district/set-location.js +82 -0
- package/clis/district/showtimes.js +433 -0
- package/clis/practo/appointment.js +21 -0
- package/clis/practo/appointments.js +27 -0
- package/clis/practo/book-confirm.js +35 -0
- package/clis/practo/book-preview.js +24 -0
- package/clis/practo/booking-link.js +24 -0
- package/clis/practo/cancel.js +29 -0
- package/clis/practo/contact.js +21 -0
- package/clis/practo/login.js +21 -0
- package/clis/practo/practo.test.js +136 -0
- package/clis/practo/profile.js +31 -0
- package/clis/practo/search.js +30 -0
- package/clis/practo/slots.js +19 -0
- package/clis/practo/utils.js +374 -0
- package/clis/practo/whoami.js +28 -0
- package/clis/reddit/popular.js +12 -1
- package/clis/reddit/popular.test.js +12 -3
- package/clis/zepto/add-to-cart.js +53 -0
- package/clis/zepto/auth.js +59 -0
- package/clis/zepto/cart.js +23 -0
- package/clis/zepto/checkout.js +60 -0
- package/clis/zepto/location.js +20 -0
- package/clis/zepto/place-order.js +47 -0
- package/clis/zepto/product.js +52 -0
- package/clis/zepto/search.js +30 -0
- package/clis/zepto/utils.js +228 -0
- package/clis/zepto/zepto.test.js +238 -0
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +12 -1
- package/dist/src/browser/protocol.d.ts +2 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
- package/dist/src/build-manifest.js +1 -0
- package/dist/src/build-manifest.test.js +34 -0
- package/dist/src/cli.js +111 -28
- package/dist/src/discovery.js +1 -0
- package/dist/src/engine.test.js +62 -0
- package/dist/src/execution.js +1 -1
- package/dist/src/generate-release-notes-cli.test.js +55 -1
- package/dist/src/manifest-types.d.ts +2 -0
- package/dist/src/registry.d.ts +10 -0
- package/dist/src/registry.js +5 -3
- package/dist/src/registry.test.js +25 -0
- package/dist/src/release-notes.d.ts +3 -1
- package/dist/src/release-notes.js +44 -1
- package/dist/src/release-notes.test.js +39 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/skills.d.ts +23 -5
- package/dist/src/skills.js +87 -45
- package/dist/src/skills.test.js +80 -23
- package/package.json +2 -2
- package/scripts/generate-release-notes.ts +31 -0
- package/skills/smart-search/SKILL.md +156 -0
- package/skills/smart-search/references/sources-ai.md +74 -0
- package/skills/smart-search/references/sources-info.md +43 -0
- package/skills/smart-search/references/sources-media.md +40 -0
- package/skills/smart-search/references/sources-other.md +32 -0
- package/skills/smart-search/references/sources-shopping.md +21 -0
- package/skills/smart-search/references/sources-social.md +36 -0
- package/skills/smart-search/references/sources-tech.md +38 -0
- package/skills/smart-search/references/sources-travel.md +26 -0
- package/skills/webcmd-adapter-author/SKILL.md +10 -0
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
- package/skills/webcmd-autofix/SKILL.md +8 -0
- package/skills/webcmd-sitemap-author/SKILL.md +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import {
|
|
3
|
+
ArgumentError,
|
|
4
|
+
AuthRequiredError,
|
|
5
|
+
CommandExecutionError,
|
|
6
|
+
EmptyResultError,
|
|
7
|
+
TimeoutError,
|
|
8
|
+
} from '@agentrhq/webcmd/errors';
|
|
9
|
+
import {
|
|
10
|
+
BookingClosedError,
|
|
11
|
+
openSeatMap,
|
|
12
|
+
profileProbe,
|
|
13
|
+
refreshShowSession,
|
|
14
|
+
resolveSeatTarget,
|
|
15
|
+
validateTimeout,
|
|
16
|
+
waitFor,
|
|
17
|
+
} from './_lib.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_SECONDS = 45;
|
|
20
|
+
|
|
21
|
+
function parseSeatList(raw) {
|
|
22
|
+
const seats = String(raw || '')
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((seat) => seat.trim().toUpperCase())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
if (!seats.length) throw new ArgumentError('seats is required, for example --seats I22,I21');
|
|
27
|
+
if (seats.length > 10) throw new ArgumentError('seats must contain 10 seats or fewer');
|
|
28
|
+
for (const seat of seats) {
|
|
29
|
+
if (!/^[A-Z]+[0-9]+$/.test(seat)) throw new ArgumentError(`invalid seat "${seat}"; use row+number like I22`);
|
|
30
|
+
}
|
|
31
|
+
if (new Set(seats).size !== seats.length) throw new ArgumentError('seats must not contain duplicates');
|
|
32
|
+
return seats;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function dismissSeatDrawer(page) {
|
|
36
|
+
await page.evaluate(`
|
|
37
|
+
(() => {
|
|
38
|
+
const controls = [...document.querySelectorAll('button,[role="button"],[data-testid="close-icon"]')];
|
|
39
|
+
const continueButton = controls.find((el) => /continue booking/i.test(el.innerText || el.getAttribute('aria-label') || ''));
|
|
40
|
+
if (continueButton) {
|
|
41
|
+
continueButton.click();
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
})()
|
|
46
|
+
`);
|
|
47
|
+
await page.wait(0.5);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function selectRequestedSeats(page, requestedSeats, timeout) {
|
|
51
|
+
const selected = [];
|
|
52
|
+
for (const seat of requestedSeats) {
|
|
53
|
+
const result = await page.evaluate(`
|
|
54
|
+
(() => {
|
|
55
|
+
const wanted = ${JSON.stringify(seat)};
|
|
56
|
+
const parse = (el) => {
|
|
57
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
58
|
+
const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
|
|
59
|
+
const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
|
|
60
|
+
const seatState = /selected/i.test(aria)
|
|
61
|
+
? 'selected'
|
|
62
|
+
: (/available/i.test(aria) ? 'available' : 'unavailable');
|
|
63
|
+
return { label: row && number ? row + number : '', seatState };
|
|
64
|
+
};
|
|
65
|
+
const candidates = [...document.querySelectorAll('#available-seat,[id="selected-seat"] span,[aria-label*="seat"]')];
|
|
66
|
+
const target = candidates.map((el) => ({ el, parsed: parse(el) })).find((item) => item.parsed.label === wanted);
|
|
67
|
+
if (!target) return { ok: false, code: 'not_found', message: wanted + ' was not found in the rendered seat map' };
|
|
68
|
+
if (target.parsed.seatState === 'selected') return { ok: true, action: 'already_selected' };
|
|
69
|
+
if (target.parsed.seatState !== 'available') return { ok: false, code: 'unavailable', message: wanted + ' is not available' };
|
|
70
|
+
target.el.click();
|
|
71
|
+
return { ok: true, action: 'clicked' };
|
|
72
|
+
})()
|
|
73
|
+
`);
|
|
74
|
+
if (!result?.ok) {
|
|
75
|
+
if (result?.code === 'not_found') throw new EmptyResultError('district checkout', result.message);
|
|
76
|
+
throw new CommandExecutionError(result?.message || `Could not select ${seat}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await waitFor(page, 'district checkout seat selection', timeout, `
|
|
80
|
+
(() => {
|
|
81
|
+
const wanted = ${JSON.stringify(seat)};
|
|
82
|
+
const selectedSeats = [...document.querySelectorAll('#selected-seat span,[aria-label^="selected class"]')].map((el) => {
|
|
83
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
84
|
+
const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
|
|
85
|
+
const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
|
|
86
|
+
return row && number ? row + number : '';
|
|
87
|
+
}).filter(Boolean);
|
|
88
|
+
const bodyText = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim().slice(0, 240) : '';
|
|
89
|
+
return { ok: selectedSeats.includes(wanted), message: bodyText };
|
|
90
|
+
})()
|
|
91
|
+
`);
|
|
92
|
+
selected.push(seat);
|
|
93
|
+
}
|
|
94
|
+
return selected;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readSelectedSeats(page) {
|
|
98
|
+
const seats = await page.evaluate(`
|
|
99
|
+
(() => {
|
|
100
|
+
return [...document.querySelectorAll('#selected-seat span,[aria-label^="selected class"]')].map((el) => {
|
|
101
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
102
|
+
const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
|
|
103
|
+
const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
|
|
104
|
+
return row && number ? row + number : '';
|
|
105
|
+
}).filter(Boolean);
|
|
106
|
+
})()
|
|
107
|
+
`);
|
|
108
|
+
return Array.isArray(seats) ? seats : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function toggleSeat(page, seat) {
|
|
112
|
+
const result = await page.evaluate(`
|
|
113
|
+
(() => {
|
|
114
|
+
const wanted = ${JSON.stringify(seat)};
|
|
115
|
+
const candidates = [...document.querySelectorAll('#available-seat,[id="selected-seat"] span,[aria-label*="seat"]')];
|
|
116
|
+
const parse = (el) => {
|
|
117
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
118
|
+
const row = ((aria.match(/row\\s+([^,\\s]+)/i) || [])[1] || '').trim().toUpperCase();
|
|
119
|
+
const number = (el.querySelector('label')?.innerText || el.innerText || '').replace(/\\s+/g, '').trim();
|
|
120
|
+
return row && number ? row + number : '';
|
|
121
|
+
};
|
|
122
|
+
const target = candidates.find((el) => parse(el) === wanted);
|
|
123
|
+
if (!target) return { ok: false, message: wanted + ' was not found in the seat map' };
|
|
124
|
+
target.click();
|
|
125
|
+
return { ok: true };
|
|
126
|
+
})()
|
|
127
|
+
`);
|
|
128
|
+
if (!result?.ok) throw new CommandExecutionError(result?.message || `Could not toggle ${seat}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* District remembers the last ticket quantity per profile and auto-selects
|
|
133
|
+
* that many adjacent seats on the first click, so the selection can contain
|
|
134
|
+
* seats nobody asked for. Deselect extras, reselect anything knocked out,
|
|
135
|
+
* and refuse to proceed until the selection matches the request exactly.
|
|
136
|
+
*/
|
|
137
|
+
async function reconcileSelection(page, requestedSeats, timeout) {
|
|
138
|
+
const wanted = new Set(requestedSeats);
|
|
139
|
+
const deadline = Date.now() + timeout * 1000;
|
|
140
|
+
let selected = await readSelectedSeats(page);
|
|
141
|
+
while (Date.now() < deadline) {
|
|
142
|
+
const extras = selected.filter((seat) => !wanted.has(seat));
|
|
143
|
+
const missing = requestedSeats.filter((seat) => !selected.includes(seat));
|
|
144
|
+
if (!extras.length && !missing.length) return;
|
|
145
|
+
for (const seat of [...extras, ...missing]) await toggleSeat(page, seat);
|
|
146
|
+
await page.wait(0.5);
|
|
147
|
+
selected = await readSelectedSeats(page);
|
|
148
|
+
}
|
|
149
|
+
throw new CommandExecutionError(
|
|
150
|
+
`District kept the selection at ${selected.join(', ') || 'no seats'} while ${requestedSeats.join(', ')} was requested; a pending booking or sticky ticket count may be interfering — open the browser tab to inspect`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function clickProceed(page, timeout) {
|
|
155
|
+
const result = await page.evaluate(`
|
|
156
|
+
(() => {
|
|
157
|
+
const controls = [...document.querySelectorAll('button,[role="button"],a')];
|
|
158
|
+
const proceed = controls.find((el) => {
|
|
159
|
+
const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
|
|
160
|
+
const label = el.getAttribute('aria-label') || '';
|
|
161
|
+
return /^Proceed$/i.test(text) || /^Proceed$/i.test(label);
|
|
162
|
+
});
|
|
163
|
+
if (!proceed) return { ok: false, message: 'Proceed button was not visible after selecting seats' };
|
|
164
|
+
proceed.click();
|
|
165
|
+
return { ok: true };
|
|
166
|
+
})()
|
|
167
|
+
`);
|
|
168
|
+
if (!result?.ok) throw new CommandExecutionError(result?.message || 'Could not click Proceed');
|
|
169
|
+
|
|
170
|
+
// District often interposes a food-and-drinks upsell drawer between seat
|
|
171
|
+
// selection and the review page; skip it while waiting.
|
|
172
|
+
await waitFor(page, 'district checkout review page', timeout, `
|
|
173
|
+
(() => {
|
|
174
|
+
const href = location.href;
|
|
175
|
+
const text = document.body ? document.body.innerText.replace(/\\s+/g, ' ').trim() : '';
|
|
176
|
+
if (!/\\/movies\\/order-review\\//.test(href) && /order food and drinks/i.test(text)) {
|
|
177
|
+
const skip = [...document.querySelectorAll('button,[role="button"]')]
|
|
178
|
+
.find((el) => /^skip$/i.test((el.innerText || '').trim()));
|
|
179
|
+
if (skip) skip.click();
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
ok: /\\/movies\\/order-review\\//.test(href) && /Pay now|Payment summary|Review your booking/i.test(text),
|
|
183
|
+
message: text.slice(0, 240)
|
|
184
|
+
};
|
|
185
|
+
})()
|
|
186
|
+
`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The order-review page paints the amounts after the header, so a single-shot
|
|
190
|
+
// read can catch loading skeletons; poll until the payable total is visible.
|
|
191
|
+
async function extractReview(page, target, seats, timeout) {
|
|
192
|
+
const result = await waitFor(page, 'district checkout payment summary', timeout, `
|
|
193
|
+
(() => {
|
|
194
|
+
const showId = ${JSON.stringify(target.showId)};
|
|
195
|
+
const seats = ${JSON.stringify(seats.join(','))};
|
|
196
|
+
const lines = (document.body?.innerText || '').split('\\n').map((line) => line.trim()).filter(Boolean);
|
|
197
|
+
const amountAfter = (label) => {
|
|
198
|
+
const index = lines.findIndex((line) => line.toLowerCase().includes(label.toLowerCase()));
|
|
199
|
+
if (index < 0) return '';
|
|
200
|
+
const amount = lines.slice(index + 1, index + 5).find((line) => /^₹\\s*[0-9,.]+/.test(line));
|
|
201
|
+
return amount || '';
|
|
202
|
+
};
|
|
203
|
+
const movie = [...document.querySelectorAll('h1')]
|
|
204
|
+
.map((el) => el.innerText.trim())
|
|
205
|
+
.find((text) => text && !/review your booking/i.test(text)) || '';
|
|
206
|
+
const ticketCount = lines.find((line) => /^\\d+ tickets?$/i.test(line)) || String(${JSON.stringify(seats.length)});
|
|
207
|
+
const seatLine = lines.find((line) => / - [A-Z]+\\d+(?:\\s*,\\s*[A-Z]+\\d+)*/.test(line)) || '';
|
|
208
|
+
const cinema = lines.find((line) => /,/.test(line) && !/^₹/.test(line) && !/District|Booking|GST|approx/i.test(line)) || '';
|
|
209
|
+
const date = lines.find((line) => /today|tomorrow|\\b\\d{1,2}\\s+[A-Z][a-z]{2}\\b/i.test(line)) || '';
|
|
210
|
+
const time = lines.find((line) => /\\b\\d{1,2}:\\d{2}\\s*[AP]M\\b.*\\b\\d{1,2}:\\d{2}\\s*[AP]M\\b/i.test(line)) || '';
|
|
211
|
+
const review = {
|
|
212
|
+
status: 'ready_for_payment',
|
|
213
|
+
movie,
|
|
214
|
+
cinema,
|
|
215
|
+
date,
|
|
216
|
+
time,
|
|
217
|
+
seats: seatLine ? seatLine.replace(/^.*? - /, '').trim() : seats,
|
|
218
|
+
ticketCount,
|
|
219
|
+
orderAmount: amountAfter('Order amount'),
|
|
220
|
+
bookingCharge: amountAfter('Booking charge'),
|
|
221
|
+
total: amountAfter('To be paid') || amountAfter('TOTAL'),
|
|
222
|
+
paymentUrl: location.href,
|
|
223
|
+
showId,
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
ok: Boolean(review.total && review.paymentUrl),
|
|
227
|
+
message: lines.slice(0, 12).join(' | ').slice(0, 240),
|
|
228
|
+
review,
|
|
229
|
+
};
|
|
230
|
+
})()
|
|
231
|
+
`);
|
|
232
|
+
return result.review;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Open the seat map, self-healing once when the show session looks stale:
|
|
237
|
+
* openSeatMap already fixes stale modals and city-mismatch; on a remaining
|
|
238
|
+
* closed-booking verdict or a seat map that never renders, one fresh
|
|
239
|
+
* showtimes lookup re-resolves the same cinema session before giving up.
|
|
240
|
+
* Only this phase retries — after seats are selected the flow never
|
|
241
|
+
* restarts, so a held selection is never doubled.
|
|
242
|
+
*/
|
|
243
|
+
async function openSeatMapWithRefresh(page, target, timeout) {
|
|
244
|
+
try {
|
|
245
|
+
return await openSeatMap(page, target, timeout);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!(error instanceof BookingClosedError) && !(error instanceof TimeoutError)) throw error;
|
|
248
|
+
const fresh = await refreshShowSession(page, target);
|
|
249
|
+
if (!fresh) {
|
|
250
|
+
throw new CommandExecutionError(
|
|
251
|
+
'District no longer offers this show session; re-run webcmd district showtimes and pick a current show',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return await openSeatMap(page, fresh, timeout);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
cli({
|
|
259
|
+
site: 'district',
|
|
260
|
+
name: 'checkout',
|
|
261
|
+
access: 'write',
|
|
262
|
+
description: 'Select District movie seats and stop at the payment handoff page',
|
|
263
|
+
domain: 'www.district.in',
|
|
264
|
+
strategy: Strategy.COOKIE,
|
|
265
|
+
browser: true,
|
|
266
|
+
navigateBefore: false,
|
|
267
|
+
defaultWindowMode: 'foreground',
|
|
268
|
+
siteSession: 'persistent',
|
|
269
|
+
// Checkout is the most state-sensitive district command: always start on a
|
|
270
|
+
// clean page so modals/drawers left by earlier commands cannot poison it.
|
|
271
|
+
freshPage: true,
|
|
272
|
+
args: [
|
|
273
|
+
{
|
|
274
|
+
name: 'show',
|
|
275
|
+
positional: true,
|
|
276
|
+
required: true,
|
|
277
|
+
help: 'District seat-layout URL or showId from district showtimes',
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'seats',
|
|
281
|
+
required: true,
|
|
282
|
+
help: 'Comma-separated seat labels to select, e.g. I22,I21',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'format-id',
|
|
286
|
+
help: 'District formatId from showtimes; required when show is a showId',
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'content-id',
|
|
290
|
+
help: 'District content id; required when show is a showId',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'timeout',
|
|
294
|
+
type: 'int',
|
|
295
|
+
default: DEFAULT_TIMEOUT_SECONDS,
|
|
296
|
+
help: 'Maximum seconds to wait for selection and review page',
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
columns: [
|
|
300
|
+
'status',
|
|
301
|
+
'movie',
|
|
302
|
+
'cinema',
|
|
303
|
+
'date',
|
|
304
|
+
'time',
|
|
305
|
+
'seats',
|
|
306
|
+
'ticketCount',
|
|
307
|
+
'orderAmount',
|
|
308
|
+
'bookingCharge',
|
|
309
|
+
'total',
|
|
310
|
+
'paymentUrl',
|
|
311
|
+
'showId',
|
|
312
|
+
],
|
|
313
|
+
func: async (page, args) => {
|
|
314
|
+
const seats = parseSeatList(args.seats);
|
|
315
|
+
const timeout = validateTimeout(args.timeout, { def: DEFAULT_TIMEOUT_SECONDS, min: 10, max: 180 });
|
|
316
|
+
|
|
317
|
+
const target = await openSeatMapWithRefresh(page, resolveSeatTarget(args), timeout);
|
|
318
|
+
|
|
319
|
+
// Gate on login before touching seats: District bounces Proceed into the
|
|
320
|
+
// OTP flow, which would waste the whole selection.
|
|
321
|
+
try {
|
|
322
|
+
await profileProbe(page);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof AuthRequiredError) {
|
|
325
|
+
throw new AuthRequiredError('www.district.in', 'District login required before checkout. Run: webcmd district login');
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await dismissSeatDrawer(page);
|
|
331
|
+
const selected = await selectRequestedSeats(page, seats, timeout);
|
|
332
|
+
await reconcileSelection(page, seats, timeout);
|
|
333
|
+
await clickProceed(page, timeout);
|
|
334
|
+
const review = await extractReview(page, target, selected, timeout);
|
|
335
|
+
|
|
336
|
+
// Final contract check: the review page is the last stop before money
|
|
337
|
+
// moves, so a resumed pending order or re-expanded selection must fail
|
|
338
|
+
// loudly here rather than hand the user the wrong tickets to pay for.
|
|
339
|
+
const reviewSeats = String(review.seats || '').split(/\s*,\s*/).filter(Boolean).sort().join(',');
|
|
340
|
+
const requested = [...seats].sort().join(',');
|
|
341
|
+
if (reviewSeats && reviewSeats !== requested) {
|
|
342
|
+
throw new CommandExecutionError(
|
|
343
|
+
`District review shows seats ${review.seats} but ${seats.join(', ')} was requested; open the browser tab and correct the order before paying`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return review;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
3
|
+
|
|
4
|
+
const BASE = 'https://www.district.in';
|
|
5
|
+
const SECTIONS = new Map([
|
|
6
|
+
['home', '/'],
|
|
7
|
+
['for-you', '/'],
|
|
8
|
+
['movies', '/movies/'],
|
|
9
|
+
['events', '/events/'],
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function decodeHtml(value) {
|
|
13
|
+
return String(value || '')
|
|
14
|
+
.replace(/<[^>]*>/g, ' ')
|
|
15
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
16
|
+
.replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)))
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, "'")
|
|
20
|
+
.replace(/ /g, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function absoluteUrl(href) {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(href, BASE).toString();
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateLimit(raw) {
|
|
34
|
+
const limit = Number(raw ?? 20);
|
|
35
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
36
|
+
throw new ArgumentError('limit must be an integer from 1 to 100');
|
|
37
|
+
}
|
|
38
|
+
return limit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildUrl(input) {
|
|
42
|
+
const value = String(input || 'home').trim();
|
|
43
|
+
const sectionPath = SECTIONS.get(value.toLowerCase());
|
|
44
|
+
if (sectionPath) return `${BASE}${sectionPath}`;
|
|
45
|
+
|
|
46
|
+
if (/^https?:\/\//i.test(value)) {
|
|
47
|
+
const parsed = new URL(value);
|
|
48
|
+
if (!/(^|\.)district\.in$/i.test(parsed.hostname)) {
|
|
49
|
+
throw new ArgumentError('input URL must be on district.in');
|
|
50
|
+
}
|
|
51
|
+
return parsed.toString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (value.startsWith('/')) return absoluteUrl(value);
|
|
55
|
+
|
|
56
|
+
throw new ArgumentError('input must be one of home, movies, events, a district.in URL, or a District path');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inferCategory(url) {
|
|
60
|
+
if (url.includes('/movies/')) return 'movie';
|
|
61
|
+
if (url.includes('/events/')) return 'event';
|
|
62
|
+
if (url.includes('/dining/')) return 'dining';
|
|
63
|
+
return 'listing';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isBookableListingUrl(url) {
|
|
67
|
+
return /\/movies\/[^/]+-movie-tickets-/i.test(url)
|
|
68
|
+
|| /\/events\/[^/]+(?:buy-tickets|ipl-ticket-booking)/i.test(url)
|
|
69
|
+
|| /\/dining\//i.test(url);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseListings(html, limit) {
|
|
73
|
+
const rows = [];
|
|
74
|
+
const anchorRe = /<a\b[^>]*href=["']([^"']+)["'][^>]*>(?=[\s\S]*?<div[^>]+class=["'][^"']*\bitem-cards\b)[\s\S]*?<\/a>/gi;
|
|
75
|
+
|
|
76
|
+
for (const match of html.matchAll(anchorRe)) {
|
|
77
|
+
const block = match[0];
|
|
78
|
+
const url = absoluteUrl(match[1]);
|
|
79
|
+
if (!url || !isBookableListingUrl(url)) continue;
|
|
80
|
+
|
|
81
|
+
const titleMatch = block.match(/<h5\b[^>]*>([\s\S]*?)<\/h5>/i);
|
|
82
|
+
const title = decodeHtml(titleMatch?.[1]);
|
|
83
|
+
if (!title) continue;
|
|
84
|
+
|
|
85
|
+
const spanValues = [...block.matchAll(/<span\b[^>]*>([\s\S]*?)<\/span>/gi)]
|
|
86
|
+
.map((span) => decodeHtml(span[1]))
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
|
|
89
|
+
const nonOfferSpans = spanValues.filter((text) => !/\boff\b|cashback|discount|coupon/i.test(text));
|
|
90
|
+
rows.push({
|
|
91
|
+
rank: rows.length + 1,
|
|
92
|
+
title,
|
|
93
|
+
category: inferCategory(url),
|
|
94
|
+
date: nonOfferSpans[0] || '',
|
|
95
|
+
venue: nonOfferSpans[1] || '',
|
|
96
|
+
price: nonOfferSpans[2] || '',
|
|
97
|
+
url,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (rows.length >= limit) break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cli({
|
|
107
|
+
site: 'district',
|
|
108
|
+
name: 'listings',
|
|
109
|
+
aliases: ['ls'],
|
|
110
|
+
access: 'read',
|
|
111
|
+
description: 'List public District by Zomato movies, events, and nearby going-out cards',
|
|
112
|
+
domain: 'www.district.in',
|
|
113
|
+
strategy: Strategy.PUBLIC,
|
|
114
|
+
browser: false,
|
|
115
|
+
args: [
|
|
116
|
+
{
|
|
117
|
+
name: 'input',
|
|
118
|
+
positional: true,
|
|
119
|
+
required: false,
|
|
120
|
+
default: 'home',
|
|
121
|
+
help: 'home, movies, events, a district.in URL, or a District path',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'limit',
|
|
125
|
+
type: 'int',
|
|
126
|
+
default: 20,
|
|
127
|
+
help: 'Maximum rows to return (1-100)',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
columns: ['rank', 'title', 'category', 'date', 'venue', 'price', 'url'],
|
|
131
|
+
func: async (args) => {
|
|
132
|
+
const limit = validateLimit(args.limit);
|
|
133
|
+
const url = buildUrl(args.input);
|
|
134
|
+
|
|
135
|
+
const resp = await fetch(url, {
|
|
136
|
+
headers: {
|
|
137
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
138
|
+
'Accept-Language': 'en-IN,en;q=0.9',
|
|
139
|
+
'User-Agent': 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!resp.ok) {
|
|
144
|
+
throw new CommandExecutionError(`district listings request failed: HTTP ${resp.status}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const html = await resp.text();
|
|
148
|
+
if (!/<html[\s>]/i.test(html)) {
|
|
149
|
+
throw new CommandExecutionError('district listings expected HTML but received a different response');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const rows = parseListings(html, limit);
|
|
153
|
+
if (!rows.length) {
|
|
154
|
+
throw new EmptyResultError('district listings', 'No listing cards found on the District page');
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
},
|
|
158
|
+
});
|