@agentrhq/webcmd 0.2.2 → 0.2.4
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/cli-manifest.json +1278 -115
- 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/checkout.js +71 -1
- 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 +48 -0
- package/clis/practo/practo.test.js +154 -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/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/daemon-lifecycle.d.ts +2 -0
- package/dist/src/browser/daemon-lifecycle.js +28 -6
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +116 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser.test.js +8 -4
- package/dist/src/cli.js +96 -0
- package/dist/src/cli.test.js +2 -2
- package/dist/src/generate-release-notes-cli.test.js +87 -13
- package/dist/src/plugin-catalog.d.ts +46 -0
- package/dist/src/plugin-catalog.js +178 -0
- package/dist/src/plugin-catalog.test.d.ts +1 -0
- package/dist/src/plugin-catalog.test.js +95 -0
- package/dist/src/release-notes.d.ts +4 -2
- package/dist/src/release-notes.js +67 -20
- package/dist/src/release-notes.test.js +67 -9
- package/package.json +2 -1
- package/plugin-catalog.json +10 -0
- package/scripts/generate-release-notes.ts +33 -4
- package/skills/webcmd-adapter-author/SKILL.md +9 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { DOMAIN, gotoBlinkit, normalizeLocationState } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const LOCATION_EVALUATE = `
|
|
5
|
+
(() => {
|
|
6
|
+
const readJson = (key) => {
|
|
7
|
+
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
|
|
8
|
+
};
|
|
9
|
+
return window.__reduxStore__?.getState?.()?.data?.location || readJson('location') || {};
|
|
10
|
+
})()
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'blinkit',
|
|
15
|
+
name: 'location',
|
|
16
|
+
access: 'read',
|
|
17
|
+
description: 'Show the selected Blinkit delivery location',
|
|
18
|
+
domain: DOMAIN,
|
|
19
|
+
strategy: Strategy.COOKIE,
|
|
20
|
+
browser: true,
|
|
21
|
+
args: [],
|
|
22
|
+
columns: ['selected', 'label', 'area', 'city', 'pincode', 'hasCoordinates', 'source'],
|
|
23
|
+
func: async (page) => {
|
|
24
|
+
await gotoBlinkit(page, '/');
|
|
25
|
+
if (page.wait) await page.wait(1);
|
|
26
|
+
return [normalizeLocationState(await page.evaluate(LOCATION_EVALUATE))];
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { DOMAIN, ensureCartHasItems, ensureLoggedIn, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function buildPlaceOrderEvaluate() {
|
|
6
|
+
return `
|
|
7
|
+
(async () => {
|
|
8
|
+
const finalLabels = [/^place order$/i, /^pay( now)?$/i, /^cash on delivery$/i];
|
|
9
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="button"], a'));
|
|
10
|
+
const target = buttons.find((node) => {
|
|
11
|
+
const text = (node.innerText || node.textContent || '').trim().replace(/\\s+/g, ' ');
|
|
12
|
+
return text && finalLabels.some((pattern) => pattern.test(text));
|
|
13
|
+
});
|
|
14
|
+
if (!target) {
|
|
15
|
+
return { ok: false, status: 'blocked', message: 'No final place-order/payment button is visible. Complete address/payment selection in the browser checkout first.' };
|
|
16
|
+
}
|
|
17
|
+
target.click();
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
19
|
+
const text = document.body.innerText || '';
|
|
20
|
+
const orderMatch = text.match(/order(?:\\s+id)?[:#\\s-]*([A-Z0-9-]{6,})/i);
|
|
21
|
+
if (/payment failed|try again|could not/i.test(text)) {
|
|
22
|
+
return { ok: false, status: 'failed', message: 'Blinkit reported a payment/order failure', url: location.href };
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, status: orderMatch ? 'placed' : 'submitted', orderId: orderMatch?.[1] || '', url: location.href };
|
|
25
|
+
})()
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
cli({
|
|
30
|
+
site: 'blinkit',
|
|
31
|
+
name: 'place-order',
|
|
32
|
+
access: 'write',
|
|
33
|
+
description: 'Submit the visible Blinkit final order/payment action. Requires --confirm.',
|
|
34
|
+
domain: DOMAIN,
|
|
35
|
+
strategy: Strategy.COOKIE,
|
|
36
|
+
browser: true,
|
|
37
|
+
navigateBefore: false,
|
|
38
|
+
defaultWindowMode: 'foreground',
|
|
39
|
+
args: [
|
|
40
|
+
{ name: 'confirm', type: 'bool', default: false, help: 'Required acknowledgement that this may place/pay for a real order' },
|
|
41
|
+
],
|
|
42
|
+
columns: ['status', 'confirmed', 'itemCount', 'payable', 'orderId', 'url', 'message'],
|
|
43
|
+
func: async (page, kwargs) => {
|
|
44
|
+
if (!kwargs.confirm) {
|
|
45
|
+
return [{
|
|
46
|
+
status: 'no-op',
|
|
47
|
+
confirmed: false,
|
|
48
|
+
message: 'Pass --confirm to submit a real Blinkit order/payment action.',
|
|
49
|
+
}];
|
|
50
|
+
}
|
|
51
|
+
if (kwargs.confirm !== true) throw new ArgumentError('--confirm must be a boolean flag');
|
|
52
|
+
|
|
53
|
+
await openCartPanel(page);
|
|
54
|
+
const state = await readCartState(page);
|
|
55
|
+
ensureLoggedIn(state, 'blinkit place-order');
|
|
56
|
+
const summary = summarizeCartResponse(state);
|
|
57
|
+
ensureCartHasItems(summary);
|
|
58
|
+
if (summary.checkoutBlocked) throw new CommandExecutionError('Blinkit checkout is blocked for this cart');
|
|
59
|
+
|
|
60
|
+
const result = await page.evaluate(buildPlaceOrderEvaluate()).catch((error) => {
|
|
61
|
+
throw new CommandExecutionError(`blinkit place-order failed: ${error?.message || error}`);
|
|
62
|
+
});
|
|
63
|
+
if (!result?.status) throw new CommandExecutionError('blinkit place-order returned no status');
|
|
64
|
+
return [{
|
|
65
|
+
status: result.status,
|
|
66
|
+
confirmed: true,
|
|
67
|
+
itemCount: summary.itemCount,
|
|
68
|
+
payable: summary.payable,
|
|
69
|
+
orderId: result.orderId || '',
|
|
70
|
+
url: result.url || '',
|
|
71
|
+
message: result.message || '',
|
|
72
|
+
}];
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const __test__ = {
|
|
77
|
+
buildPlaceOrderEvaluate,
|
|
78
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { DOMAIN, normalizeProductSnippet, requireProductId, resolveCoordinates } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function buildProductEvaluate(productId, lat, lon) {
|
|
6
|
+
return `
|
|
7
|
+
(async () => {
|
|
8
|
+
const headers = { lat: ${JSON.stringify(lat)}, lon: ${JSON.stringify(lon)}, app_client: 'consumer_web' };
|
|
9
|
+
const resp = await fetch('/v1/layout/product/' + ${JSON.stringify(productId)}, { method: 'POST', credentials: 'include', headers });
|
|
10
|
+
const raw = await resp.text();
|
|
11
|
+
let json;
|
|
12
|
+
try { json = JSON.parse(raw); } catch { return { ok: false, status: resp.status, error: raw.slice(0, 200) }; }
|
|
13
|
+
if (!resp.ok || json.error) return { ok: false, status: resp.status, error: json.error || json.message || raw.slice(0, 200) };
|
|
14
|
+
return { ok: true, snippets: json?.response?.snippets || [] };
|
|
15
|
+
})()
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeProduct(snippets, productId) {
|
|
20
|
+
const title = snippets.find((snippet) => snippet?.widget_type === 'text_right_icons_rating_snippet_type');
|
|
21
|
+
const strip = snippets.find((snippet) => snippet?.widget_type === 'product_atc_strip');
|
|
22
|
+
const row = normalizeProductSnippet(strip) ?? normalizeProductSnippet(title);
|
|
23
|
+
if (!row) return null;
|
|
24
|
+
const titleText = title?.data?.title?.text;
|
|
25
|
+
return { ...row, productId, name: titleText || row.name };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
cli({
|
|
29
|
+
site: 'blinkit',
|
|
30
|
+
name: 'product',
|
|
31
|
+
access: 'read',
|
|
32
|
+
description: 'Read Blinkit product details for a delivery location',
|
|
33
|
+
domain: DOMAIN,
|
|
34
|
+
strategy: Strategy.COOKIE,
|
|
35
|
+
browser: true,
|
|
36
|
+
navigateBefore: false,
|
|
37
|
+
args: [
|
|
38
|
+
{ name: 'productId', required: true, positional: true, help: 'Blinkit product id' },
|
|
39
|
+
{ name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
|
|
40
|
+
{ name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
|
|
41
|
+
],
|
|
42
|
+
columns: ['productId', 'name', 'brand', 'variant', 'price', 'mrp', 'currency', 'inventory', 'available', 'imageUrl', 'url'],
|
|
43
|
+
func: async (page, kwargs) => {
|
|
44
|
+
const productId = requireProductId(kwargs.productId);
|
|
45
|
+
await page.goto(`https://blinkit.com/prn/x/prid/${productId}`).catch((error) => {
|
|
46
|
+
throw new CommandExecutionError(`blinkit product navigation failed: ${error?.message || error}`);
|
|
47
|
+
});
|
|
48
|
+
const { lat, lon } = await resolveCoordinates(page, kwargs);
|
|
49
|
+
const result = await page.evaluate(buildProductEvaluate(productId, lat, lon)).catch((error) => {
|
|
50
|
+
throw new CommandExecutionError(`blinkit product request failed: ${error?.message || error}`);
|
|
51
|
+
});
|
|
52
|
+
if (!result?.ok) {
|
|
53
|
+
throw new CommandExecutionError(`blinkit product failed: HTTP ${result?.status ?? 'unknown'} ${result?.error ?? ''}`.trim());
|
|
54
|
+
}
|
|
55
|
+
const row = normalizeProduct(result.snippets ?? [], productId);
|
|
56
|
+
if (!row) throw new EmptyResultError('blinkit product', `No product data for ${productId}`);
|
|
57
|
+
return [row];
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const __test__ = {
|
|
62
|
+
normalizeProduct,
|
|
63
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { BASE, MAX_LIMIT, normalizeProductSnippet, parseCoordinate, parseLimit, parsePrice, resolveCoordinates } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function buildSearchEvaluate(query, limit, lat, lon) {
|
|
6
|
+
return `
|
|
7
|
+
(async () => {
|
|
8
|
+
const query = ${JSON.stringify(query)};
|
|
9
|
+
const limit = ${limit};
|
|
10
|
+
const headers = {
|
|
11
|
+
lat: ${JSON.stringify(lat)},
|
|
12
|
+
lon: ${JSON.stringify(lon)},
|
|
13
|
+
app_client: 'consumer_web'
|
|
14
|
+
};
|
|
15
|
+
const rows = [];
|
|
16
|
+
let url = '/v1/layout/search?q=' + encodeURIComponent(query) + '&search_type=type_to_search';
|
|
17
|
+
|
|
18
|
+
for (let page = 0; url && rows.length < limit && page < 8; page += 1) {
|
|
19
|
+
const resp = await fetch(url, { method: 'POST', credentials: 'include', headers });
|
|
20
|
+
const raw = await resp.text();
|
|
21
|
+
let json;
|
|
22
|
+
try {
|
|
23
|
+
json = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return { ok: false, status: resp.status, error: raw.slice(0, 200) };
|
|
26
|
+
}
|
|
27
|
+
if (!resp.ok || json.error) {
|
|
28
|
+
return { ok: false, status: resp.status, error: json.error || json.message || raw.slice(0, 200) };
|
|
29
|
+
}
|
|
30
|
+
const snippets = Array.isArray(json?.response?.snippets) ? json.response.snippets : [];
|
|
31
|
+
rows.push(...snippets.filter((snippet) => snippet?.widget_type === 'product_card_snippet_type_2'));
|
|
32
|
+
url = json?.response?.pagination?.next_url || '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ok: true, rows: rows.slice(0, limit) };
|
|
36
|
+
})()
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
cli({
|
|
41
|
+
site: 'blinkit',
|
|
42
|
+
name: 'search',
|
|
43
|
+
access: 'read',
|
|
44
|
+
description: 'Search Blinkit products for a delivery location',
|
|
45
|
+
domain: 'blinkit.com',
|
|
46
|
+
strategy: Strategy.COOKIE,
|
|
47
|
+
browser: true,
|
|
48
|
+
navigateBefore: false,
|
|
49
|
+
args: [
|
|
50
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
51
|
+
{ name: 'limit', type: 'int', default: 20, help: `Max results (max ${MAX_LIMIT})` },
|
|
52
|
+
{ name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
|
|
53
|
+
{ name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
|
|
54
|
+
],
|
|
55
|
+
columns: ['rank', 'productId', 'name', 'brand', 'variant', 'price', 'mrp', 'currency', 'inventory', 'available', 'imageUrl', 'url'],
|
|
56
|
+
func: async (page, kwargs) => {
|
|
57
|
+
const query = String(kwargs.query ?? '').trim();
|
|
58
|
+
if (!query) throw new ArgumentError('query cannot be empty');
|
|
59
|
+
|
|
60
|
+
const limit = parseLimit(kwargs.limit);
|
|
61
|
+
const pageUrl = `${BASE}/s/?q=${encodeURIComponent(query)}`;
|
|
62
|
+
await page.goto(pageUrl).catch((error) => {
|
|
63
|
+
throw new CommandExecutionError(`blinkit search navigation failed: ${error?.message || error}`);
|
|
64
|
+
});
|
|
65
|
+
const { lat, lon } = await resolveCoordinates(page, kwargs);
|
|
66
|
+
|
|
67
|
+
const result = await page.evaluate(buildSearchEvaluate(query, limit, lat, lon)).catch((error) => {
|
|
68
|
+
throw new CommandExecutionError(`blinkit search request failed: ${error?.message || error}`);
|
|
69
|
+
});
|
|
70
|
+
if (!result?.ok) {
|
|
71
|
+
throw new CommandExecutionError(`blinkit search failed: HTTP ${result?.status ?? 'unknown'} ${result?.error ?? ''}`.trim());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rows = (result.rows ?? [])
|
|
75
|
+
.map((snippet, index) => normalizeProductSnippet(snippet, index + 1))
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (!rows.length) {
|
|
78
|
+
throw new EmptyResultError('blinkit search', `No products matched "${query}" at ${lat},${lon}`);
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const __test__ = {
|
|
85
|
+
normalizeSnippet: normalizeProductSnippet,
|
|
86
|
+
parseCoordinate,
|
|
87
|
+
parseLimit,
|
|
88
|
+
parsePrice,
|
|
89
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
|
|
3
|
+
export const BASE = 'https://blinkit.com';
|
|
4
|
+
export const DOMAIN = 'blinkit.com';
|
|
5
|
+
export const DEFAULT_LAT = '28.413333';
|
|
6
|
+
export const DEFAULT_LON = '77.072833';
|
|
7
|
+
export const MAX_LIMIT = 48;
|
|
8
|
+
|
|
9
|
+
export function parseLimit(raw) {
|
|
10
|
+
if (raw === undefined || raw === null || raw === '') return 20;
|
|
11
|
+
const limit = Number(raw);
|
|
12
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
13
|
+
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
14
|
+
}
|
|
15
|
+
return limit;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseQuantity(raw) {
|
|
19
|
+
const quantity = raw === undefined || raw === null || raw === '' ? 1 : Number(raw);
|
|
20
|
+
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 12) {
|
|
21
|
+
throw new ArgumentError('--quantity must be an integer between 1 and 12');
|
|
22
|
+
}
|
|
23
|
+
return quantity;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseCoordinate(raw, label, fallback) {
|
|
27
|
+
const value = raw === undefined || raw === null || raw === '' ? fallback : String(raw);
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
const max = label === 'lat' ? 90 : 180;
|
|
30
|
+
if (!Number.isFinite(n) || n < -max || n > max) {
|
|
31
|
+
throw new ArgumentError(`--${label} must be a number between ${-max} and ${max}`);
|
|
32
|
+
}
|
|
33
|
+
return String(n);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveCoordinates(page, kwargs = {}) {
|
|
37
|
+
const explicitLat = kwargs.lat !== undefined && kwargs.lat !== null && kwargs.lat !== '';
|
|
38
|
+
const explicitLon = kwargs.lon !== undefined && kwargs.lon !== null && kwargs.lon !== '';
|
|
39
|
+
const location = explicitLat && explicitLon ? null : await page.evaluate(`
|
|
40
|
+
(() => {
|
|
41
|
+
const readJson = (key) => {
|
|
42
|
+
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
|
|
43
|
+
};
|
|
44
|
+
const coords = window.__reduxStore__?.getState?.()?.data?.location?.coords || readJson('location')?.coords || {};
|
|
45
|
+
return { lat: coords.lat, lon: coords.lon };
|
|
46
|
+
})()
|
|
47
|
+
`).catch(() => null);
|
|
48
|
+
return {
|
|
49
|
+
lat: parseCoordinate(explicitLat ? kwargs.lat : location?.lat, 'lat', DEFAULT_LAT),
|
|
50
|
+
lon: parseCoordinate(explicitLon ? kwargs.lon : location?.lon, 'lon', DEFAULT_LON),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeLocationState(location = {}) {
|
|
55
|
+
const coords = location.coords || {};
|
|
56
|
+
return {
|
|
57
|
+
selected: Boolean(location.locality || location.area || location.city || location.pinCode || location.pincode || coords.lat || coords.lon),
|
|
58
|
+
label: String(location.label || location.name || location.type || '').trim(),
|
|
59
|
+
area: String(location.locality || location.area || location.landmark || '').trim(),
|
|
60
|
+
city: String(location.city || location.cityName || '').trim(),
|
|
61
|
+
pincode: String(location.pinCode || location.pincode || location.pin || '').trim(),
|
|
62
|
+
hasCoordinates: Boolean(coords.lat || coords.lon),
|
|
63
|
+
source: 'browser',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function requireProductId(raw) {
|
|
68
|
+
const value = String(raw ?? '').trim();
|
|
69
|
+
const match = value.match(/(?:prid\/)?(\d{3,})$/) || value.match(/\/prid\/(\d{3,})/);
|
|
70
|
+
if (!match) throw new ArgumentError('productId must be a Blinkit product id, for example 19512');
|
|
71
|
+
return match[1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parsePrice(text) {
|
|
75
|
+
const match = String(text ?? '').replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
|
|
76
|
+
return match ? Number(match[1]) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function text(node) {
|
|
80
|
+
return String(node?.text ?? '').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function productUrl(productId) {
|
|
84
|
+
return productId ? `${BASE}/prn/x/prid/${productId}` : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function normalizeProductSnippet(snippet, rank = undefined) {
|
|
88
|
+
const data = snippet?.data ?? {};
|
|
89
|
+
const cart = data.atc_action?.add_to_cart?.cart_item
|
|
90
|
+
?? data.stepper_data_v2?.increment_actions?.default?.find((action) => action?.add_to_cart)?.add_to_cart?.cart_item
|
|
91
|
+
?? data.rfc_actions_v2?.default?.find((action) => action?.remove_from_cart)?.remove_from_cart?.cart_item
|
|
92
|
+
?? {};
|
|
93
|
+
const productId = String(data.product_id ?? data.meta?.product_id ?? cart.product_id ?? data.identity?.id ?? '').trim();
|
|
94
|
+
const name = text(data.display_name) || text(data.name) || text(data.title) || String(cart.product_name ?? cart.display_name ?? '').trim();
|
|
95
|
+
if (!productId || !name) return null;
|
|
96
|
+
|
|
97
|
+
const price = cart.price ?? parsePrice(text(data.normal_price) || text(data.info_text));
|
|
98
|
+
const mrp = cart.mrp ?? parsePrice(text(data.mrp));
|
|
99
|
+
return {
|
|
100
|
+
...(rank === undefined ? {} : { rank }),
|
|
101
|
+
productId,
|
|
102
|
+
name,
|
|
103
|
+
brand: text(data.brand_name) || String(cart.brand ?? '').trim(),
|
|
104
|
+
variant: text(data.variant) || String(cart.unit ?? '').trim(),
|
|
105
|
+
price: Number.isFinite(Number(price)) ? Number(price) : null,
|
|
106
|
+
mrp: Number.isFinite(Number(mrp)) ? Number(mrp) : null,
|
|
107
|
+
currency: 'INR',
|
|
108
|
+
inventory: Number.isFinite(Number(data.inventory ?? cart.inventory)) ? Number(data.inventory ?? cart.inventory) : null,
|
|
109
|
+
available: data.is_sold_out === true ? false : data.product_state !== 'unavailable',
|
|
110
|
+
imageUrl: data.image?.url || cart.image_url || '',
|
|
111
|
+
url: productUrl(productId),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeCartItem(item, fallbackId = '') {
|
|
116
|
+
const product = item?.product ?? item ?? {};
|
|
117
|
+
const productId = String(product.product_id ?? item?.product_id ?? fallbackId ?? '').trim();
|
|
118
|
+
const quantity = Number(item?.quantity ?? product.quantity ?? 0);
|
|
119
|
+
if (!productId || !quantity) return null;
|
|
120
|
+
const price = Number(product.price ?? product.unit_price ?? parsePrice(product.total_price));
|
|
121
|
+
return {
|
|
122
|
+
productId,
|
|
123
|
+
name: String(product.name ?? product.product_name ?? product.display_name ?? '').trim(),
|
|
124
|
+
variant: String(product.unit ?? '').trim(),
|
|
125
|
+
price: Number.isFinite(price) ? price : null,
|
|
126
|
+
quantity,
|
|
127
|
+
total: Number.isFinite(price) ? price * quantity : null,
|
|
128
|
+
inventory: Number.isFinite(Number(product.inventory_limit ?? product.inventory)) ? Number(product.inventory_limit ?? product.inventory) : null,
|
|
129
|
+
merchantId: String(product.merchant_id ?? '').trim(),
|
|
130
|
+
imageUrl: product.image_url || product.image_url_v2 || product.png_image_url || '',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function summarizeCartResponse(response) {
|
|
135
|
+
const cartData = response?.cart_data ?? {};
|
|
136
|
+
const bill = cartData.bill_details ?? {};
|
|
137
|
+
const items = Array.isArray(cartData.items)
|
|
138
|
+
? cartData.items.map((item) => normalizeCartItem(item)).filter(Boolean)
|
|
139
|
+
: [];
|
|
140
|
+
const storedItems = response?.storedCart?.items && typeof response.storedCart.items === 'object'
|
|
141
|
+
? Object.entries(response.storedCart.items).map(([id, item]) => normalizeCartItem(item, id)).filter(Boolean)
|
|
142
|
+
: [];
|
|
143
|
+
const finalItems = items.length ? items : storedItems;
|
|
144
|
+
return {
|
|
145
|
+
status: finalItems.length ? 'ok' : 'empty',
|
|
146
|
+
itemCount: Number(bill.total_items ?? response?.storedCart?.count ?? finalItems.reduce((sum, item) => sum + item.quantity, 0) ?? 0),
|
|
147
|
+
itemsTotal: Number(bill.total_cost ?? response?.storedCart?.total ?? 0),
|
|
148
|
+
deliveryCharge: Number(bill.delivery_charge ?? 0),
|
|
149
|
+
handlingCharge: Number(bill.additional_charge ?? 0),
|
|
150
|
+
payable: Number(bill.payable_amount ?? bill.bill_total ?? response?.storedCart?.total ?? 0),
|
|
151
|
+
cartState: response?.cart_state ?? cartData.cart_state ?? response?.storedCart?.cart_state ?? '',
|
|
152
|
+
checkoutBlocked: Boolean(cartData.checkout_block_details),
|
|
153
|
+
validations: (cartData.validations ?? []).map((validation) => validation?.code).filter(Boolean).join(','),
|
|
154
|
+
items: finalItems,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function gotoBlinkit(page, path = '/') {
|
|
159
|
+
await page.goto(`${BASE}${path}`).catch((error) => {
|
|
160
|
+
throw new CommandExecutionError(`blinkit navigation failed: ${error?.message || error}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function openCartPanel(page) {
|
|
165
|
+
await gotoBlinkit(page, '/');
|
|
166
|
+
await page.wait(1);
|
|
167
|
+
await page.evaluate(`
|
|
168
|
+
(() => {
|
|
169
|
+
const nodes = Array.from(document.querySelectorAll('[role="button"], header div, header button, header a'));
|
|
170
|
+
const target = nodes
|
|
171
|
+
.filter((node) => {
|
|
172
|
+
const value = node.innerText || node.textContent || '';
|
|
173
|
+
return /My Cart/i.test(value) || (/\\d+\\s+items?/i.test(value) && /₹\\s*\\d+/i.test(value));
|
|
174
|
+
})
|
|
175
|
+
.sort((a, b) => (a.innerText || a.textContent || '').length - (b.innerText || b.textContent || '').length)[0];
|
|
176
|
+
target?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
|
177
|
+
return Boolean(target);
|
|
178
|
+
})()
|
|
179
|
+
`).catch((error) => {
|
|
180
|
+
throw new CommandExecutionError(`blinkit cart open failed: ${error?.message || error}`);
|
|
181
|
+
});
|
|
182
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
183
|
+
await page.wait(1);
|
|
184
|
+
const ready = await page.evaluate(`
|
|
185
|
+
(() => Boolean(window.__reduxStore__?.getState?.()?.ui?.cart?.cartScreen?.cartResponse?.cart_data))
|
|
186
|
+
`).catch(() => false);
|
|
187
|
+
if (ready) return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readCartState(page) {
|
|
192
|
+
const result = await page.evaluate(`
|
|
193
|
+
(() => {
|
|
194
|
+
const readJson = (key) => {
|
|
195
|
+
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
|
|
196
|
+
};
|
|
197
|
+
const state = window.__reduxStore__?.getState?.();
|
|
198
|
+
const response = state?.ui?.cart?.cartScreen?.cartResponse || null;
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
loggedIn: !/^Login$/m.test(document.body.innerText || '') && Boolean(readJson('auth')?.accessToken || state?.data?.auth?.accessToken),
|
|
202
|
+
response,
|
|
203
|
+
storedCart: state?.data?.cart || readJson('cart') || null,
|
|
204
|
+
checkout: state?.ui?.checkout || readJson('checkout') || null,
|
|
205
|
+
location: state?.data?.location || readJson('location') || null,
|
|
206
|
+
};
|
|
207
|
+
})()
|
|
208
|
+
`).catch((error) => {
|
|
209
|
+
throw new CommandExecutionError(`blinkit cart state read failed: ${error?.message || error}`);
|
|
210
|
+
});
|
|
211
|
+
if (!result?.ok) throw new CommandExecutionError('blinkit cart state read failed');
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function ensureLoggedIn(state, action = 'Blinkit command') {
|
|
216
|
+
if (!state?.loggedIn) {
|
|
217
|
+
throw new AuthRequiredError(DOMAIN, `${action} requires a logged-in Blinkit browser session. Run webcmd blinkit login first.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function ensureCartHasItems(summary) {
|
|
222
|
+
if (!summary.itemCount) throw new EmptyResultError('blinkit cart', 'Cart is empty');
|
|
223
|
+
}
|
|
@@ -94,6 +94,63 @@ async function selectRequestedSeats(page, requestedSeats, timeout) {
|
|
|
94
94
|
return selected;
|
|
95
95
|
}
|
|
96
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
|
+
|
|
97
154
|
async function clickProceed(page, timeout) {
|
|
98
155
|
const result = await page.evaluate(`
|
|
99
156
|
(() => {
|
|
@@ -272,7 +329,20 @@ cli({
|
|
|
272
329
|
|
|
273
330
|
await dismissSeatDrawer(page);
|
|
274
331
|
const selected = await selectRequestedSeats(page, seats, timeout);
|
|
332
|
+
await reconcileSelection(page, seats, timeout);
|
|
275
333
|
await clickProceed(page, timeout);
|
|
276
|
-
|
|
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;
|
|
277
347
|
},
|
|
278
348
|
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { driveJson, normalizeAppointmentId } from './utils.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'practo',
|
|
6
|
+
name: 'appointment',
|
|
7
|
+
access: 'read',
|
|
8
|
+
description: 'Show logged-in Practo Drive appointment details',
|
|
9
|
+
domain: 'drive.practo.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
navigateBefore: false,
|
|
13
|
+
siteSession: 'persistent',
|
|
14
|
+
args: [{ name: 'appointment_id', positional: true, required: true, help: 'Appointment id from `practo appointments`' }],
|
|
15
|
+
columns: ['appointment_id', 'status', 'summary'],
|
|
16
|
+
func: async (page, kwargs) => {
|
|
17
|
+
const id = normalizeAppointmentId(kwargs.appointment_id);
|
|
18
|
+
const data = await driveJson(page, `/api/record/v2/appointment/${encodeURIComponent(id)}`);
|
|
19
|
+
return [{ appointment_id: id, status: data?.status || data?.data?.status || '', summary: JSON.stringify(data?.data ?? data).slice(0, 1000) }];
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
+
import { driveJson } from './utils.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'practo',
|
|
6
|
+
name: 'appointments',
|
|
7
|
+
access: 'read',
|
|
8
|
+
description: 'List logged-in Practo Drive appointments',
|
|
9
|
+
domain: 'drive.practo.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
navigateBefore: false,
|
|
13
|
+
siteSession: 'persistent',
|
|
14
|
+
args: [],
|
|
15
|
+
columns: ['appointment_id', 'doctor', 'practice', 'time', 'status'],
|
|
16
|
+
func: async (page) => {
|
|
17
|
+
const data = await driveJson(page, '/api/record/v2/appointments');
|
|
18
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
19
|
+
return list.map((item) => ({
|
|
20
|
+
appointment_id: String(item.id || item.appointment_id || item.object_identifier || ''),
|
|
21
|
+
doctor: item.doctor_name || item.provider_name || item.doctor?.name || '',
|
|
22
|
+
practice: item.practice_name || item.establishment_name || item.practice?.name || '',
|
|
23
|
+
time: item.appointment_time || item.scheduled_at || item.from_time || '',
|
|
24
|
+
status: item.status || item.state || '',
|
|
25
|
+
}));
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { bookingPreviewFromPageData, normalizeConfirm, prepareBookingPage, submitBookingPage } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'practo',
|
|
7
|
+
name: 'book-confirm',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Confirm a Practo clinic visit booking after explicit confirmation',
|
|
10
|
+
domain: 'www.practo.com',
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
defaultWindowMode: 'foreground',
|
|
15
|
+
siteSession: 'persistent',
|
|
16
|
+
args: [
|
|
17
|
+
{ name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id' },
|
|
18
|
+
{ name: 'time', required: true, help: 'Slot time YYYY-MM-DD HH:mm:ss' },
|
|
19
|
+
{ name: 'profile-url', required: false, help: 'Doctor profile_url from `practo search`, used to build a canonical booking URL' },
|
|
20
|
+
{ name: 'confirm', type: 'boolean', default: false, help: 'Required. Set --confirm true to create the appointment.' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['status', 'practice_doctor_id', 'time', 'url'],
|
|
23
|
+
func: async (page, kwargs) => {
|
|
24
|
+
if (!normalizeConfirm(kwargs.confirm)) {
|
|
25
|
+
throw new ArgumentError('Refusing to book appointment without --confirm true', 'Example: webcmd practo book-confirm 859054 --time "2026-07-10 10:30:00" --confirm true');
|
|
26
|
+
}
|
|
27
|
+
const prepared = await prepareBookingPage(page, kwargs);
|
|
28
|
+
const preview = bookingPreviewFromPageData(prepared.data, prepared.context)[0];
|
|
29
|
+
if (preview.requires_payment) {
|
|
30
|
+
throw new CommandExecutionError('This Practo slot appears to require online payment; stopping before payment.');
|
|
31
|
+
}
|
|
32
|
+
const result = await submitBookingPage(page);
|
|
33
|
+
return [{ status: 'submitted', practice_doctor_id: prepared.context.practiceDoctorId, time: prepared.context.slotTime, url: result.url || prepared.context.url }];
|
|
34
|
+
},
|
|
35
|
+
});
|