@agentrhq/webcmd 0.2.1 → 0.2.2
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 +504 -0
- package/clis/_shared/site-auth.js +6 -1
- package/clis/district/_lib.js +566 -0
- package/clis/district/auth.js +49 -0
- package/clis/district/checkout.js +278 -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/reddit/popular.js +12 -1
- package/clis/reddit/popular.test.js +12 -3
- 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/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/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/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 +1 -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,433 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
3
|
+
import {
|
|
4
|
+
BASE,
|
|
5
|
+
DEFAULT_LOCATION,
|
|
6
|
+
browserLocation,
|
|
7
|
+
districtHeaders,
|
|
8
|
+
fetchMovieSessions,
|
|
9
|
+
} from './_lib.js';
|
|
10
|
+
|
|
11
|
+
function validateText(raw, name, max = 160) {
|
|
12
|
+
const value = String(raw || '').trim();
|
|
13
|
+
if (!value) throw new ArgumentError(`${name} is required`);
|
|
14
|
+
if (value.length > max) throw new ArgumentError(`${name} must be ${max} characters or fewer`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateOptionalText(raw, name, max = 120) {
|
|
19
|
+
if (raw == null || raw === '') return '';
|
|
20
|
+
return validateText(raw, name, max);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateLimit(raw) {
|
|
24
|
+
const limit = Number(raw ?? 50);
|
|
25
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
|
26
|
+
throw new ArgumentError('limit must be an integer from 1 to 200');
|
|
27
|
+
}
|
|
28
|
+
return limit;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateMoney(raw, name) {
|
|
32
|
+
if (raw == null || raw === '') return 0;
|
|
33
|
+
const value = Number(raw);
|
|
34
|
+
if (!Number.isFinite(value) || value < 0) throw new ArgumentError(`${name} must be a positive number`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateCityKey(raw) {
|
|
39
|
+
if (raw == null || raw === '') return '';
|
|
40
|
+
const cityKey = String(raw).trim().toLowerCase();
|
|
41
|
+
if (!/^[a-z0-9-]+$/.test(cityKey)) {
|
|
42
|
+
throw new ArgumentError('city-key must contain only lowercase letters, numbers, and hyphens');
|
|
43
|
+
}
|
|
44
|
+
return cityKey;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateDate(raw) {
|
|
48
|
+
if (raw == null || raw === '') return '';
|
|
49
|
+
const date = String(raw).trim();
|
|
50
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new ArgumentError('date must be in YYYY-MM-DD format');
|
|
51
|
+
return date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateClock(raw, name) {
|
|
55
|
+
if (raw == null || raw === '') return null;
|
|
56
|
+
const value = String(raw).trim();
|
|
57
|
+
const match = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
|
|
58
|
+
if (!match) throw new ArgumentError(`${name} must be in HH:MM 24-hour format`);
|
|
59
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalize(value) {
|
|
63
|
+
return String(value || '')
|
|
64
|
+
.trim()
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
67
|
+
.replace(/\s+/g, ' ')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stateKey(value) {
|
|
72
|
+
return normalize(value).replace(/\s+/g, '-');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function slugify(value) {
|
|
76
|
+
return String(value || '')
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/[\W_]+/g, '-')
|
|
79
|
+
.replace(/^-+|-+$/g, '')
|
|
80
|
+
.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractContentId(value) {
|
|
84
|
+
const match = String(value || '').match(/(?:MV)?(\d{4,})/i);
|
|
85
|
+
return match ? match[1] : '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDistrictMovieUrl(value) {
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(value, BASE);
|
|
91
|
+
return url.hostname.endsWith('district.in') && /\/movies\/.+MV\d+/i.test(url.pathname);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeBookingUrl(movieUrl, cityKey) {
|
|
98
|
+
const url = new URL(movieUrl, BASE);
|
|
99
|
+
const match = url.pathname.match(/^\/movies\/(.+?)-movie-tickets(?:-in-[a-z0-9-]+)?-MV(\d+)/i);
|
|
100
|
+
if (!match) throw new ArgumentError('movie must be a District movie URL or a movie search query');
|
|
101
|
+
url.pathname = `/movies/${match[1]}-movie-tickets-in-${cityKey}-MV${match[2]}`;
|
|
102
|
+
return url;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function searchMovie(query, loc) {
|
|
106
|
+
const body = {
|
|
107
|
+
get_search_results_request_type: 1,
|
|
108
|
+
post_body: { hp_selected_tab_id: 'home_v2' },
|
|
109
|
+
search_id: crypto.randomUUID(),
|
|
110
|
+
keyword: query,
|
|
111
|
+
tab_id: 'movies',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const resp = await fetch(`${BASE}/gw/web/search`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: {
|
|
117
|
+
...districtHeaders({ loc, referer: `${BASE}/search` }),
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
Origin: BASE,
|
|
120
|
+
'x-available-tabs': 'movies',
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!resp.ok) throw new CommandExecutionError(`district movie search failed: HTTP ${resp.status}`);
|
|
126
|
+
const data = await resp.json();
|
|
127
|
+
const movie = (Array.isArray(data?.results) ? data.results : []).find((item) => {
|
|
128
|
+
return String(item.entity_type || '') === 'EntityTypeMovie' && item.id;
|
|
129
|
+
});
|
|
130
|
+
if (!movie) throw new EmptyResultError('district showtimes', `No movie result found for "${query}"`);
|
|
131
|
+
return `${BASE}/movies/${slugify(movie.display_title)}-movie-tickets-MV${movie.id}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchLocationSearch(body) {
|
|
135
|
+
const resp = await fetch(`${BASE}/gw/web/get_location_search`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
...districtHeaders({ loc: DEFAULT_LOCATION, referer: BASE }),
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
Origin: BASE,
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify(body),
|
|
143
|
+
});
|
|
144
|
+
if (!resp.ok) throw new CommandExecutionError(`district location search failed: HTTP ${resp.status}`);
|
|
145
|
+
return resp.json();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function distanceKm(aLat, aLng, bLat, bLng) {
|
|
149
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
150
|
+
const r = 6371;
|
|
151
|
+
const dLat = toRad(bLat - aLat);
|
|
152
|
+
const dLng = toRad(bLng - aLng);
|
|
153
|
+
const lat1 = toRad(aLat);
|
|
154
|
+
const lat2 = toRad(bLat);
|
|
155
|
+
const a = Math.sin(dLat / 2) ** 2
|
|
156
|
+
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
|
157
|
+
return 2 * r * Math.asin(Math.sqrt(a));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function nearestCity(entity, cities) {
|
|
161
|
+
const lat = Number(entity.lat);
|
|
162
|
+
const lng = Number(entity.long);
|
|
163
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
|
164
|
+
let nearest = null;
|
|
165
|
+
for (const city of cities) {
|
|
166
|
+
const cityLat = Number(city.city_lat);
|
|
167
|
+
const cityLng = Number(city.city_long);
|
|
168
|
+
if (!Number.isFinite(cityLat) || !Number.isFinite(cityLng)) continue;
|
|
169
|
+
const km = distanceKm(lat, lng, cityLat, cityLng);
|
|
170
|
+
if (!nearest || km < nearest.km) nearest = { city, km };
|
|
171
|
+
}
|
|
172
|
+
return nearest && nearest.km <= 80 ? nearest.city : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cityToLocation(city, source = 'city') {
|
|
176
|
+
return {
|
|
177
|
+
cityId: city.city_id,
|
|
178
|
+
pCityId: city.city_id,
|
|
179
|
+
long: Number(city.city_long),
|
|
180
|
+
lat: Number(city.city_lat),
|
|
181
|
+
pCityKey: String(city.city_key || '').toLowerCase(),
|
|
182
|
+
pStateKey: stateKey(city.state_name),
|
|
183
|
+
countryId: '1',
|
|
184
|
+
placeType: 'GOOGLE_PLACE',
|
|
185
|
+
placeId: '',
|
|
186
|
+
subzoneId: '',
|
|
187
|
+
cityName: String(city.city_name || ''),
|
|
188
|
+
pCityName: String(city.city_name || ''),
|
|
189
|
+
source,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function entityToLocation(entity, city, source = 'near') {
|
|
194
|
+
const base = city ? cityToLocation(city, source) : { ...DEFAULT_LOCATION, source };
|
|
195
|
+
return {
|
|
196
|
+
...base,
|
|
197
|
+
long: Number(entity.long || base.long),
|
|
198
|
+
lat: Number(entity.lat || base.lat),
|
|
199
|
+
placeType: entity.google_place_id ? 'GOOGLE_PLACE' : 'POI',
|
|
200
|
+
placeId: String(entity.google_place_id || entity.poi_id || ''),
|
|
201
|
+
cityName: String(city?.city_name || base.cityName || ''),
|
|
202
|
+
pCityName: String(city?.city_name || base.pCityName || ''),
|
|
203
|
+
source,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function resolveLocationQuery(query, preferCity) {
|
|
208
|
+
const [searchData, allData] = await Promise.all([
|
|
209
|
+
fetchLocationSearch({ searched_text: query }),
|
|
210
|
+
fetchLocationSearch({}),
|
|
211
|
+
]);
|
|
212
|
+
const cities = Array.isArray(allData?.cities) ? allData.cities : [];
|
|
213
|
+
const q = normalize(query);
|
|
214
|
+
if (preferCity) {
|
|
215
|
+
const exactCity = cities.find((city) => {
|
|
216
|
+
return [city.city_name, city.cleaned_city_name, city.city_key].map(normalize).includes(q);
|
|
217
|
+
});
|
|
218
|
+
if (exactCity) return cityToLocation(exactCity, 'city');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const entities = Array.isArray(searchData?.entities) ? searchData.entities : [];
|
|
222
|
+
const entity = preferCity
|
|
223
|
+
? entities.find((item) => String(item.entity_type || '').includes('CITY')) || entities[0]
|
|
224
|
+
: entities.find((item) => normalize(item.title) === q) || entities[0];
|
|
225
|
+
if (!entity) throw new EmptyResultError('district showtimes', `No location found for "${query}"`);
|
|
226
|
+
const city = String(entity.entity_type || '').includes('CITY')
|
|
227
|
+
? cities.find((item) => normalize(item.city_name) === normalize(entity.title) || normalize(item.city_key) === q)
|
|
228
|
+
: nearestCity(entity, cities);
|
|
229
|
+
return String(entity.entity_type || '').includes('CITY')
|
|
230
|
+
? cityToLocation(city || { city_id: '', city_key: '', city_lat: entity.lat, city_long: entity.long, city_name: entity.title, state_name: '' }, 'city')
|
|
231
|
+
: entityToLocation(entity, city, 'near');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function resolveCityKey(cityKey) {
|
|
235
|
+
const allData = await fetchLocationSearch({});
|
|
236
|
+
const city = (Array.isArray(allData?.cities) ? allData.cities : []).find((item) => {
|
|
237
|
+
return normalize(item.city_key) === normalize(cityKey);
|
|
238
|
+
});
|
|
239
|
+
return city ? cityToLocation(city, 'city-key') : { ...DEFAULT_LOCATION, pCityKey: cityKey, source: 'city-key' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function extractNextData(html) {
|
|
243
|
+
const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
|
|
244
|
+
if (!match) throw new CommandExecutionError('District booking page did not include session data');
|
|
245
|
+
try {
|
|
246
|
+
return JSON.parse(match[1]);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
throw new CommandExecutionError(`District session data could not be parsed: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pricesFor(areas) {
|
|
253
|
+
return (Array.isArray(areas) ? areas : [])
|
|
254
|
+
.map((area) => Number(area.price))
|
|
255
|
+
.filter((price) => Number.isFinite(price));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function moneyRange(areas) {
|
|
259
|
+
const prices = pricesFor(areas);
|
|
260
|
+
if (!prices.length) return '';
|
|
261
|
+
const min = Math.min(...prices);
|
|
262
|
+
const max = Math.max(...prices);
|
|
263
|
+
return min === max ? `INR ${min}` : `INR ${min}-${max}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function formatDateTime(value, options) {
|
|
267
|
+
if (!value) return '';
|
|
268
|
+
const date = new Date(`${value}Z`);
|
|
269
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
270
|
+
return new Intl.DateTimeFormat('en-IN', { timeZone: 'Asia/Kolkata', ...options }).format(date);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sessionMinute(value) {
|
|
274
|
+
const time = formatDateTime(value, { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
275
|
+
const match = time.match(/^(\d{2}):(\d{2})$/);
|
|
276
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function languageFor(movie) {
|
|
280
|
+
const languages = Array.isArray(movie.languages) ? movie.languages : [];
|
|
281
|
+
return String(movie.primary_language || languages[0] || '').trim();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function matchesText(value, filter) {
|
|
285
|
+
return !filter || normalize(value).includes(normalize(filter));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function rowsFromPayload(payload, loc, formatId, contentId, filters, limit) {
|
|
289
|
+
const movie = payload?.meta?.movie || {};
|
|
290
|
+
const selectedDate = payload?.meta?.selectedShowDate || '';
|
|
291
|
+
const language = languageFor(movie);
|
|
292
|
+
const cinemas = [
|
|
293
|
+
...(Array.isArray(payload?.pageData?.nearbyCinemas) ? payload.pageData.nearbyCinemas : []),
|
|
294
|
+
...(Array.isArray(payload?.pageData?.farCinemas) ? payload.pageData.farCinemas : []),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const rows = [];
|
|
298
|
+
for (const cinema of cinemas) {
|
|
299
|
+
const info = cinema.cinemaInfo || {};
|
|
300
|
+
const cinemaName = String(info.name || info.label || '');
|
|
301
|
+
if (!matchesText(cinemaName, filters.cinema)) continue;
|
|
302
|
+
|
|
303
|
+
for (const session of Array.isArray(cinema.sessions) ? cinema.sessions : []) {
|
|
304
|
+
const minute = sessionMinute(session.showTime);
|
|
305
|
+
const format = String(session.scrnFmt || '');
|
|
306
|
+
const prices = pricesFor(session.areas);
|
|
307
|
+
if (filters.after != null && minute != null && minute < filters.after) continue;
|
|
308
|
+
if (filters.before != null && minute != null && minute > filters.before) continue;
|
|
309
|
+
if (filters.maxPrice && prices.length && Math.min(...prices) > filters.maxPrice) continue;
|
|
310
|
+
if (!matchesText(language, filters.language)) continue;
|
|
311
|
+
if (!matchesText(format, filters.quality)) continue;
|
|
312
|
+
|
|
313
|
+
const encSessionId = String(session.encSessionId || `${session.cid}-${session.sid}-${String(session.mid || '').toLowerCase()}-${session.cid}`);
|
|
314
|
+
rows.push({
|
|
315
|
+
rank: rows.length + 1,
|
|
316
|
+
movie: String(movie.name || ''),
|
|
317
|
+
language,
|
|
318
|
+
date: selectedDate || formatDateTime(session.showTime, { year: 'numeric', month: '2-digit', day: '2-digit' }),
|
|
319
|
+
time: formatDateTime(session.showTime, { hour: '2-digit', minute: '2-digit', hour12: true }),
|
|
320
|
+
cinema: cinemaName,
|
|
321
|
+
format,
|
|
322
|
+
priceRange: moneyRange(session.areas),
|
|
323
|
+
available: Number(session.avail || 0),
|
|
324
|
+
showId: encSessionId,
|
|
325
|
+
formatId: String(formatId || session.mcd || ''),
|
|
326
|
+
url: `${BASE}/movies/seat-layout/${formatId || session.mcd}?encsessionid=${encodeURIComponent(encSessionId)}&freeseating=false&fromsessions=true&type=MOVIES&contentid=${contentId}&fromdate=${selectedDate}&citykey=${encodeURIComponent(loc.pCityKey || '')}`,
|
|
327
|
+
});
|
|
328
|
+
if (rows.length >= limit) return rows;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return rows;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function resolveLocation(page, args) {
|
|
335
|
+
const city = validateOptionalText(args.city, 'city');
|
|
336
|
+
const near = validateOptionalText(args.near, 'near');
|
|
337
|
+
const cityKey = validateCityKey(args['city-key']);
|
|
338
|
+
if (near) return resolveLocationQuery(near, false);
|
|
339
|
+
if (city) return resolveLocationQuery(city, true);
|
|
340
|
+
if (cityKey) return resolveCityKey(cityKey);
|
|
341
|
+
return browserLocation(page);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
cli({
|
|
345
|
+
site: 'district',
|
|
346
|
+
name: 'showtimes',
|
|
347
|
+
aliases: ['shows'],
|
|
348
|
+
access: 'read',
|
|
349
|
+
description: 'List District movie showtimes with location, time, cinema, language, price, and format filters',
|
|
350
|
+
domain: 'www.district.in',
|
|
351
|
+
strategy: Strategy.COOKIE,
|
|
352
|
+
browser: true,
|
|
353
|
+
navigateBefore: false,
|
|
354
|
+
defaultWindowMode: 'foreground',
|
|
355
|
+
siteSession: 'persistent',
|
|
356
|
+
args: [
|
|
357
|
+
{ name: 'movie', positional: true, required: true, help: 'Movie name or District movie URL' },
|
|
358
|
+
{ name: 'date', help: 'Show date in YYYY-MM-DD format; defaults to District selected date' },
|
|
359
|
+
{ name: 'city', help: 'District city name/key, for example Bangalore or Bengaluru' },
|
|
360
|
+
{ name: 'near', help: 'Area, mall, or locality to search near, for example Indiranagar' },
|
|
361
|
+
{ name: 'city-key', help: 'Legacy District city key override, for example bengaluru' },
|
|
362
|
+
{ name: 'after', help: 'Only shows at or after HH:MM, 24-hour time' },
|
|
363
|
+
{ name: 'before', help: 'Only shows at or before HH:MM, 24-hour time' },
|
|
364
|
+
{ name: 'cinema', help: 'Filter cinema/theatre name, for example PVR, INOX, Orion' },
|
|
365
|
+
{ name: 'language', help: 'Filter movie language, for example English, Hindi, Kannada' },
|
|
366
|
+
{ name: 'max-price', type: 'float', help: 'Only shows with at least one ticket class at or below this price' },
|
|
367
|
+
{ name: 'quality', help: 'Generic format/quality filter, for example 2D, 3D, IMAX, IMAX 3D, 4DX' },
|
|
368
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Maximum showtime rows to return (1-200)' },
|
|
369
|
+
],
|
|
370
|
+
columns: [
|
|
371
|
+
'rank',
|
|
372
|
+
'movie',
|
|
373
|
+
'language',
|
|
374
|
+
'date',
|
|
375
|
+
'time',
|
|
376
|
+
'cinema',
|
|
377
|
+
'format',
|
|
378
|
+
'priceRange',
|
|
379
|
+
'available',
|
|
380
|
+
'showId',
|
|
381
|
+
'formatId',
|
|
382
|
+
'url',
|
|
383
|
+
],
|
|
384
|
+
func: async (page, args) => {
|
|
385
|
+
const movieInput = validateText(args.movie, 'movie');
|
|
386
|
+
const date = validateDate(args.date);
|
|
387
|
+
const limit = validateLimit(args.limit);
|
|
388
|
+
const filters = {
|
|
389
|
+
after: validateClock(args.after, 'after'),
|
|
390
|
+
before: validateClock(args.before, 'before'),
|
|
391
|
+
cinema: validateOptionalText(args.cinema, 'cinema'),
|
|
392
|
+
language: validateOptionalText(args.language, 'language'),
|
|
393
|
+
maxPrice: validateMoney(args['max-price'], 'max-price'),
|
|
394
|
+
quality: validateOptionalText(args.quality, 'quality'),
|
|
395
|
+
};
|
|
396
|
+
const loc = await resolveLocation(page, args);
|
|
397
|
+
if (!loc.pCityKey) throw new CommandExecutionError('Could not resolve District city key for showtimes');
|
|
398
|
+
|
|
399
|
+
const movieUrl = isDistrictMovieUrl(movieInput) ? movieInput : await searchMovie(movieInput, loc);
|
|
400
|
+
const contentId = extractContentId(movieUrl);
|
|
401
|
+
const bookingUrl = makeBookingUrl(movieUrl, loc.pCityKey);
|
|
402
|
+
if (date) bookingUrl.searchParams.set('fromdate', date);
|
|
403
|
+
|
|
404
|
+
const resp = await fetch(bookingUrl, {
|
|
405
|
+
headers: districtHeaders({
|
|
406
|
+
loc,
|
|
407
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
408
|
+
referer: movieUrl,
|
|
409
|
+
}),
|
|
410
|
+
});
|
|
411
|
+
if (!resp.ok) throw new CommandExecutionError(`district booking page failed: HTTP ${resp.status}`);
|
|
412
|
+
|
|
413
|
+
const nextData = extractNextData(await resp.text());
|
|
414
|
+
const state = nextData.props?.pageProps?.initialState?.movies || {};
|
|
415
|
+
const formatId = state.queryState?.frmtid || Object.keys(state.movieSessions || {})[0] || '';
|
|
416
|
+
const payload = await fetchMovieSessions({
|
|
417
|
+
loc,
|
|
418
|
+
contentId,
|
|
419
|
+
formatId,
|
|
420
|
+
date,
|
|
421
|
+
referer: bookingUrl.toString(),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const showDates = Array.isArray(payload?.meta?.showDates) ? payload.meta.showDates : [];
|
|
425
|
+
if (date && showDates.length && !showDates.includes(date)) {
|
|
426
|
+
throw new EmptyResultError('district showtimes', `No showtimes found for ${date}; available dates: ${showDates.join(', ')}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const rows = rowsFromPayload(payload, loc, formatId, contentId, filters, limit);
|
|
430
|
+
if (!rows.length) throw new EmptyResultError('district showtimes', `No showtimes matched the requested filters for "${movieInput}"`);
|
|
431
|
+
return rows;
|
|
432
|
+
},
|
|
433
|
+
});
|
package/clis/reddit/popular.js
CHANGED
|
@@ -12,6 +12,7 @@ cli({
|
|
|
12
12
|
],
|
|
13
13
|
columns: ['rank', 'id', 'title', 'subreddit', 'score', 'comments', 'author', 'url', 'created_utc', 'selftext', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
|
|
14
14
|
pipeline: [
|
|
15
|
+
{ navigate: 'https://www.reddit.com' },
|
|
15
16
|
{ evaluate: `(async () => {
|
|
16
17
|
function decodeHtml(s) {
|
|
17
18
|
if (typeof s !== 'string' || !s) return '';
|
|
@@ -43,7 +44,17 @@ cli({
|
|
|
43
44
|
const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {
|
|
44
45
|
credentials: 'include'
|
|
45
46
|
});
|
|
46
|
-
const
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw new Error('Reddit popular request failed: HTTP ' + res.status + ' ' + text.replace(/\\s+/g, ' ').slice(0, 160));
|
|
50
|
+
}
|
|
51
|
+
let d;
|
|
52
|
+
try {
|
|
53
|
+
d = JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
const kind = /^\\s*</.test(text) ? 'HTML' : 'non-JSON';
|
|
56
|
+
throw new Error('Reddit popular expected JSON but received ' + kind + ': ' + text.replace(/\\s+/g, ' ').slice(0, 160));
|
|
57
|
+
}
|
|
47
58
|
return (d?.data?.children || []).map(c => ({
|
|
48
59
|
id: c.data.id,
|
|
49
60
|
title: c.data.title,
|
|
@@ -4,6 +4,8 @@ import './popular.js';
|
|
|
4
4
|
|
|
5
5
|
describe('reddit popular adapter', () => {
|
|
6
6
|
const command = getRegistry().get('reddit/popular');
|
|
7
|
+
const evaluate = command?.pipeline?.find((step) => step.evaluate)?.evaluate;
|
|
8
|
+
const map = command?.pipeline?.find((step) => step.map)?.map;
|
|
7
9
|
|
|
8
10
|
it('exposes the full post-list shape including the 4 media columns', () => {
|
|
9
11
|
expect(command?.columns).toEqual([
|
|
@@ -14,13 +16,20 @@ describe('reddit popular adapter', () => {
|
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
it('surfaces media via extractRedditMedia in evaluate + map', () => {
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
19
|
-
expect(
|
|
19
|
+
expect(evaluate).toContain('function extractRedditMedia');
|
|
20
|
+
expect(evaluate).toContain('...extractRedditMedia(c.data)');
|
|
21
|
+
expect(map).toMatchObject({
|
|
20
22
|
post_hint: '${{ item.post_hint }}',
|
|
21
23
|
url_overridden_by_dest: '${{ item.url_overridden_by_dest }}',
|
|
22
24
|
preview_image_url: '${{ item.preview_image_url }}',
|
|
23
25
|
gallery_urls: '${{ item.gallery_urls }}',
|
|
24
26
|
});
|
|
25
27
|
});
|
|
28
|
+
|
|
29
|
+
it('navigates to Reddit and guards HTML responses before JSON parsing', () => {
|
|
30
|
+
expect(command?.pipeline?.[0]).toEqual({ navigate: 'https://www.reddit.com' });
|
|
31
|
+
expect(evaluate).toContain('await res.text()');
|
|
32
|
+
expect(evaluate).toContain('Reddit popular expected JSON');
|
|
33
|
+
expect(evaluate).not.toContain('await res.json()');
|
|
34
|
+
});
|
|
26
35
|
});
|
|
@@ -21,6 +21,7 @@ export declare class BrowserBridge implements IBrowserFactory {
|
|
|
21
21
|
windowMode?: 'foreground' | 'background';
|
|
22
22
|
surface?: 'browser' | 'adapter';
|
|
23
23
|
siteSession?: 'ephemeral' | 'persistent';
|
|
24
|
+
freshPage?: boolean;
|
|
24
25
|
}): Promise<IPage>;
|
|
25
26
|
close(): Promise<void>;
|
|
26
27
|
private _ensureDaemon;
|
|
@@ -32,7 +32,7 @@ export class BrowserBridge {
|
|
|
32
32
|
await this._ensureDaemon(opts.timeout, routing.contextId);
|
|
33
33
|
if (!opts.session?.trim())
|
|
34
34
|
throw new Error('Browser session is required');
|
|
35
|
-
this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
|
|
35
|
+
this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage);
|
|
36
36
|
this._state = 'connected';
|
|
37
37
|
return this._page;
|
|
38
38
|
}
|
|
@@ -21,7 +21,10 @@ export declare class Page extends BasePage {
|
|
|
21
21
|
private readonly siteSession?;
|
|
22
22
|
readonly preferredContextId?: string | undefined;
|
|
23
23
|
private readonly _idleTimeout;
|
|
24
|
-
constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined);
|
|
24
|
+
constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined, freshPage?: boolean);
|
|
25
|
+
/** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
|
|
26
|
+
private _freshPagePending;
|
|
27
|
+
private _freshPageOpts;
|
|
25
28
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
26
29
|
private _page;
|
|
27
30
|
private _networkCaptureUnsupported;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -42,7 +42,7 @@ export class Page extends BasePage {
|
|
|
42
42
|
siteSession;
|
|
43
43
|
preferredContextId;
|
|
44
44
|
_idleTimeout;
|
|
45
|
-
constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId) {
|
|
45
|
+
constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId, freshPage) {
|
|
46
46
|
super();
|
|
47
47
|
this.session = session;
|
|
48
48
|
this.contextId = contextId;
|
|
@@ -51,6 +51,15 @@ export class Page extends BasePage {
|
|
|
51
51
|
this.siteSession = siteSession;
|
|
52
52
|
this.preferredContextId = preferredContextId;
|
|
53
53
|
this._idleTimeout = idleTimeout;
|
|
54
|
+
this._freshPagePending = freshPage === true;
|
|
55
|
+
}
|
|
56
|
+
/** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
|
|
57
|
+
_freshPagePending;
|
|
58
|
+
_freshPageOpts() {
|
|
59
|
+
if (!this._freshPagePending)
|
|
60
|
+
return {};
|
|
61
|
+
this._freshPagePending = false;
|
|
62
|
+
return { freshPage: true };
|
|
54
63
|
}
|
|
55
64
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
56
65
|
_page;
|
|
@@ -66,6 +75,7 @@ export class Page extends BasePage {
|
|
|
66
75
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
67
76
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
68
77
|
...(this.siteSession && { siteSession: this.siteSession }),
|
|
78
|
+
...this._freshPageOpts(),
|
|
69
79
|
};
|
|
70
80
|
}
|
|
71
81
|
/** Helper: spread session + page identity into command params */
|
|
@@ -79,6 +89,7 @@ export class Page extends BasePage {
|
|
|
79
89
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
80
90
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
81
91
|
...(this.siteSession && { siteSession: this.siteSession }),
|
|
92
|
+
...this._freshPageOpts(),
|
|
82
93
|
};
|
|
83
94
|
}
|
|
84
95
|
async goto(url, options) {
|
|
@@ -10,6 +10,8 @@ export interface BrowserRuntimeCommand {
|
|
|
10
10
|
session?: string;
|
|
11
11
|
surface?: BrowserSurface;
|
|
12
12
|
siteSession?: SiteSessionMode;
|
|
13
|
+
/** Close any existing leased page and start on a new one (sent on the first action of a command run). */
|
|
14
|
+
freshPage?: boolean;
|
|
13
15
|
url?: string;
|
|
14
16
|
op?: string;
|
|
15
17
|
index?: number;
|
|
@@ -9,6 +9,8 @@ export interface SessionKeyInput {
|
|
|
9
9
|
surface?: BrowserSurface;
|
|
10
10
|
siteSession?: SiteSessionMode;
|
|
11
11
|
idleTimeout?: number;
|
|
12
|
+
/** Discard the existing leased page (if any) and create a new one under the same lease. */
|
|
13
|
+
freshPage?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface CloakPageLease {
|
|
14
16
|
profileId: string;
|
|
@@ -40,15 +40,25 @@ export class CloakSessionManager {
|
|
|
40
40
|
const surface = normalizeSurface(input.surface);
|
|
41
41
|
const leaseKey = resolveLeaseKey(input);
|
|
42
42
|
const runtime = await this.getProfileRuntime(profileId);
|
|
43
|
+
const freshPage = input.freshPage === true;
|
|
43
44
|
const existing = runtime.pages.get(leaseKey);
|
|
44
|
-
if (existing && !pageIsClosed(existing.page)) {
|
|
45
|
+
if (existing && !pageIsClosed(existing.page) && !freshPage) {
|
|
45
46
|
runtime.lastSeenAt = Date.now();
|
|
46
47
|
existing.idleTimeout = input.idleTimeout;
|
|
47
48
|
this.refreshIdleTimer(runtime, leaseKey, existing);
|
|
48
49
|
return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
|
|
49
50
|
}
|
|
51
|
+
if (existing && freshPage) {
|
|
52
|
+
runtime.pages.delete(leaseKey);
|
|
53
|
+
this.clearIdleTimer(existing);
|
|
54
|
+
if (runtime.selectedPageId === existing.pageId)
|
|
55
|
+
runtime.selectedPageId = undefined;
|
|
56
|
+
if (!pageIsClosed(existing.page))
|
|
57
|
+
await existing.page.close().catch(() => { });
|
|
58
|
+
}
|
|
50
59
|
const existingPages = runtime.context.pages();
|
|
51
|
-
|
|
60
|
+
// freshPage must never adopt a leftover tab — its whole point is a clean DOM.
|
|
61
|
+
const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
|
|
52
62
|
const pageId = nextPageId();
|
|
53
63
|
const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
|
|
54
64
|
runtime.pages.set(leaseKey, entry);
|