@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.
Files changed (100) hide show
  1. package/README.md +39 -24
  2. package/cli-manifest.json +2308 -650
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/bigbasket/add-to-cart.js +82 -0
  5. package/clis/bigbasket/bigbasket.test.js +255 -0
  6. package/clis/bigbasket/cart.js +81 -0
  7. package/clis/bigbasket/category.js +30 -0
  8. package/clis/bigbasket/checkout.js +71 -0
  9. package/clis/bigbasket/location.js +30 -0
  10. package/clis/bigbasket/product.js +79 -0
  11. package/clis/bigbasket/search.js +30 -0
  12. package/clis/bigbasket/utils.js +207 -0
  13. package/clis/blinkit/add-to-cart.js +123 -0
  14. package/clis/blinkit/auth.js +99 -0
  15. package/clis/blinkit/blinkit.test.js +168 -0
  16. package/clis/blinkit/cart.js +34 -0
  17. package/clis/blinkit/checkout.js +32 -0
  18. package/clis/blinkit/location.js +29 -0
  19. package/clis/blinkit/place-order.js +78 -0
  20. package/clis/blinkit/product.js +63 -0
  21. package/clis/blinkit/search.js +89 -0
  22. package/clis/blinkit/utils.js +223 -0
  23. package/clis/district/_lib.js +566 -0
  24. package/clis/district/auth.js +49 -0
  25. package/clis/district/checkout.js +348 -0
  26. package/clis/district/listings.js +158 -0
  27. package/clis/district/locations.js +211 -0
  28. package/clis/district/search.js +218 -0
  29. package/clis/district/seats.js +233 -0
  30. package/clis/district/set-location.js +82 -0
  31. package/clis/district/showtimes.js +433 -0
  32. package/clis/practo/appointment.js +21 -0
  33. package/clis/practo/appointments.js +27 -0
  34. package/clis/practo/book-confirm.js +35 -0
  35. package/clis/practo/book-preview.js +24 -0
  36. package/clis/practo/booking-link.js +24 -0
  37. package/clis/practo/cancel.js +29 -0
  38. package/clis/practo/contact.js +21 -0
  39. package/clis/practo/login.js +21 -0
  40. package/clis/practo/practo.test.js +136 -0
  41. package/clis/practo/profile.js +31 -0
  42. package/clis/practo/search.js +30 -0
  43. package/clis/practo/slots.js +19 -0
  44. package/clis/practo/utils.js +374 -0
  45. package/clis/practo/whoami.js +28 -0
  46. package/clis/reddit/popular.js +12 -1
  47. package/clis/reddit/popular.test.js +12 -3
  48. package/clis/zepto/add-to-cart.js +53 -0
  49. package/clis/zepto/auth.js +59 -0
  50. package/clis/zepto/cart.js +23 -0
  51. package/clis/zepto/checkout.js +60 -0
  52. package/clis/zepto/location.js +20 -0
  53. package/clis/zepto/place-order.js +47 -0
  54. package/clis/zepto/product.js +52 -0
  55. package/clis/zepto/search.js +30 -0
  56. package/clis/zepto/utils.js +228 -0
  57. package/clis/zepto/zepto.test.js +238 -0
  58. package/dist/src/browser/bridge.d.ts +1 -0
  59. package/dist/src/browser/bridge.js +1 -1
  60. package/dist/src/browser/page.d.ts +4 -1
  61. package/dist/src/browser/page.js +12 -1
  62. package/dist/src/browser/protocol.d.ts +2 -0
  63. package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
  64. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
  65. package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
  66. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
  67. package/dist/src/build-manifest.js +1 -0
  68. package/dist/src/build-manifest.test.js +34 -0
  69. package/dist/src/cli.js +111 -28
  70. package/dist/src/discovery.js +1 -0
  71. package/dist/src/engine.test.js +62 -0
  72. package/dist/src/execution.js +1 -1
  73. package/dist/src/generate-release-notes-cli.test.js +55 -1
  74. package/dist/src/manifest-types.d.ts +2 -0
  75. package/dist/src/registry.d.ts +10 -0
  76. package/dist/src/registry.js +5 -3
  77. package/dist/src/registry.test.js +25 -0
  78. package/dist/src/release-notes.d.ts +3 -1
  79. package/dist/src/release-notes.js +44 -1
  80. package/dist/src/release-notes.test.js +39 -1
  81. package/dist/src/runtime.d.ts +2 -0
  82. package/dist/src/runtime.js +1 -0
  83. package/dist/src/skills.d.ts +23 -5
  84. package/dist/src/skills.js +87 -45
  85. package/dist/src/skills.test.js +80 -23
  86. package/package.json +2 -2
  87. package/scripts/generate-release-notes.ts +31 -0
  88. package/skills/smart-search/SKILL.md +156 -0
  89. package/skills/smart-search/references/sources-ai.md +74 -0
  90. package/skills/smart-search/references/sources-info.md +43 -0
  91. package/skills/smart-search/references/sources-media.md +40 -0
  92. package/skills/smart-search/references/sources-other.md +32 -0
  93. package/skills/smart-search/references/sources-shopping.md +21 -0
  94. package/skills/smart-search/references/sources-social.md +36 -0
  95. package/skills/smart-search/references/sources-tech.md +38 -0
  96. package/skills/smart-search/references/sources-travel.md +26 -0
  97. package/skills/webcmd-adapter-author/SKILL.md +10 -0
  98. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  99. package/skills/webcmd-autofix/SKILL.md +8 -0
  100. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { normalizePracticeDoctorId, practoJson } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'contact',
7
+ access: 'read',
8
+ description: 'Get Practo virtual contact number for a practice_doctor_id',
9
+ domain: 'www.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [{ name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id from search results' }],
14
+ columns: ['practice_doctor_id', 'phone', 'raw'],
15
+ func: async (page, kwargs) => {
16
+ const id = normalizePracticeDoctorId(kwargs.practice_doctor_id);
17
+ const data = await practoJson(page, `/health/api/vn/vnpractice?practice_doctor_id=${id}`);
18
+ const phone = data?.phone_number || data?.phone || data?.number || data?.data?.phone_number || '';
19
+ return [{ practice_doctor_id: id, phone, raw: phone ? '' : JSON.stringify(data).slice(0, 500) }];
20
+ },
21
+ });
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { PRACTO } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'login',
7
+ access: 'write',
8
+ description: 'Open Practo login for manual sign-in',
9
+ domain: 'www.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ defaultWindowMode: 'foreground',
14
+ siteSession: 'persistent',
15
+ args: [],
16
+ columns: ['status', 'site', 'message'],
17
+ func: async (page) => {
18
+ await page.goto(`${PRACTO}/login`);
19
+ return [{ status: 'opened', site: 'practo', message: 'Finish login in the browser, then run `webcmd practo whoami`.' }];
20
+ },
21
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError } from '@agentrhq/webcmd/errors';
3
+ import { getRegistry } from '@agentrhq/webcmd/registry';
4
+ import './appointment.js';
5
+ import './appointments.js';
6
+ import './book-confirm.js';
7
+ import './book-preview.js';
8
+ import './booking-link.js';
9
+ import './cancel.js';
10
+ import './contact.js';
11
+ import './login.js';
12
+ import './profile.js';
13
+ import './search.js';
14
+ import './slots.js';
15
+ import './whoami.js';
16
+ import { __test__ } from './utils.js';
17
+
18
+ const {
19
+ buildSearchUrl,
20
+ buildBookingUrl,
21
+ normalizeConfirm,
22
+ normalizeLimit,
23
+ normalizePracticeDoctorId,
24
+ normalizeSlotTime,
25
+ rowsFromSearchState,
26
+ rowsFromSlotsPayload,
27
+ } = __test__;
28
+
29
+ describe('practo helpers', () => {
30
+ it('builds city/specialty search URLs', () => {
31
+ expect(buildSearchUrl({ city: 'Bangalore', specialty: 'Orthopedist' })).toBe('https://www.practo.com/bangalore/orthopedist');
32
+ expect(buildSearchUrl({ city: 'New Delhi', specialty: 'Dentist', locality: 'South Extension' })).toBe('https://www.practo.com/new-delhi/dentist/south-extension');
33
+ });
34
+
35
+ it('validates identity, limits, slot times, and confirmations', () => {
36
+ expect(normalizePracticeDoctorId('859054')).toBe('859054');
37
+ expect(normalizeLimit('3')).toBe(3);
38
+ expect(normalizeSlotTime('2026-07-10 10:30:00')).toBe('2026-07-10 10:30:00');
39
+ expect(normalizeConfirm(true)).toBe(true);
40
+ expect(normalizeConfirm('true')).toBe(true);
41
+ expect(() => normalizePracticeDoctorId('abc')).toThrow(ArgumentError);
42
+ expect(() => normalizeLimit(0)).toThrow(ArgumentError);
43
+ expect(() => normalizeSlotTime('tomorrow')).toThrow(ArgumentError);
44
+ });
45
+
46
+ it('maps listing redux doctors into stable search rows', () => {
47
+ const rows = rowsFromSearchState({
48
+ listingV2: {
49
+ doctors: {
50
+ entities: {
51
+ 1: {
52
+ id: 859054,
53
+ doctor_id: 1056089,
54
+ practice_id: 776696,
55
+ doctor_name: 'Dr. Nithin Patel',
56
+ specialization: 'Orthopedist',
57
+ experience_years: 12,
58
+ locality: 'Koramangala',
59
+ city: 'Bangalore',
60
+ practice: { name: 'Practo Clinic' },
61
+ consultation_fees: 1000,
62
+ profile_url: '/bangalore/doctor/dr-nithin-patel-orthopedist',
63
+ next_available_timestamp: '2026-07-10T10:30:00+05:30',
64
+ },
65
+ },
66
+ },
67
+ },
68
+ }, 1);
69
+ expect(rows).toEqual([{
70
+ rank: 1,
71
+ practice_doctor_id: '859054',
72
+ doctor_id: '1056089',
73
+ practice_id: '776696',
74
+ name: 'Dr. Nithin Patel',
75
+ specialty: 'Orthopedist',
76
+ experience_years: 12,
77
+ locality: 'Koramangala',
78
+ city: 'Bangalore',
79
+ clinic: 'Practo Clinic',
80
+ fee: 1000,
81
+ next_available: '2026-07-10T10:30:00+05:30',
82
+ profile_url: 'https://www.practo.com/bangalore/doctor/dr-nithin-patel-orthopedist',
83
+ }]);
84
+ });
85
+
86
+ it('maps slot payloads into rows and booking URLs', () => {
87
+ const rows = rowsFromSlotsPayload({
88
+ practice_doctor_id: 859054,
89
+ amount: 1000,
90
+ prepaid: false,
91
+ appointment_token: 'abc',
92
+ slots: [{
93
+ datestamp: '2026-07-10',
94
+ slots: [{ time: '10:00', timeslots: [{ ts: '2026-07-10 10:30:00', available: true }] }],
95
+ }],
96
+ });
97
+ expect(rows).toEqual([{
98
+ practice_doctor_id: '859054',
99
+ time: '2026-07-10 10:30:00',
100
+ available: true,
101
+ amount: 1000,
102
+ prepaid: false,
103
+ appointment_token: 'abc',
104
+ }]);
105
+ expect(buildBookingUrl({ practiceDoctorId: '859054', slotTime: rows[0].time, appointmentToken: 'abc', amount: 1000, prepaid: false }))
106
+ .toContain('/appointment/doctor/859054/book?');
107
+ });
108
+ });
109
+
110
+ describe('practo command registry', () => {
111
+ it('registers the approved command surface', () => {
112
+ for (const name of ['whoami', 'login', 'search', 'profile', 'slots', 'contact', 'booking-link', 'appointments', 'appointment', 'book-preview', 'book-confirm', 'cancel']) {
113
+ expect(getRegistry().get(`practo/${name}`)).toBeDefined();
114
+ }
115
+ expect(getRegistry().get('practo/auth-status')).toBe(getRegistry().get('practo/whoami'));
116
+ });
117
+
118
+ it('marks writes as explicit confirm commands', () => {
119
+ const book = getRegistry().get('practo/book-confirm');
120
+ const cancel = getRegistry().get('practo/cancel');
121
+ expect(book.access).toBe('write');
122
+ expect(cancel.access).toBe('write');
123
+ expect(book.args.find((arg) => arg.name === 'confirm')?.type).toBe('boolean');
124
+ expect(cancel.args.find((arg) => arg.name === 'confirm')?.type).toBe('boolean');
125
+ });
126
+
127
+ it('refuses real booking without --confirm true', async () => {
128
+ const book = getRegistry().get('practo/book-confirm');
129
+ await expect(book.func({}, { practice_doctor_id: '859054', time: '2026-07-10 10:30:00' })).rejects.toThrow(ArgumentError);
130
+ });
131
+
132
+ it('refuses cancellation without --confirm true', async () => {
133
+ const cancel = getRegistry().get('practo/cancel');
134
+ await expect(cancel.func({}, { appointment_id: 'abc' })).rejects.toThrow(ArgumentError);
135
+ });
136
+ });
@@ -0,0 +1,31 @@
1
+ import { EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { PRACTO, unwrapBrowserResult } from './utils.js';
4
+
5
+ cli({
6
+ site: 'practo',
7
+ name: 'profile',
8
+ access: 'read',
9
+ description: 'Read public details from a Practo doctor profile URL',
10
+ domain: 'www.practo.com',
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ args: [{ name: 'url', positional: true, required: true, help: 'Practo doctor profile URL' }],
15
+ columns: ['name', 'specialty', 'experience', 'fee', 'profile_url'],
16
+ func: async (page, kwargs) => {
17
+ const url = new URL(String(kwargs.url), PRACTO).toString();
18
+ await page.goto(url);
19
+ await page.wait(2);
20
+ const row = unwrapBrowserResult(await page.evaluate(`(() => {
21
+ const text = document.body?.innerText || '';
22
+ const title = document.querySelector('h1')?.textContent?.trim() || document.title.replace(/ - Practo.*/, '').trim();
23
+ const specialty = (text.match(/(Orthopedist|Dentist|Dermatologist|Gynecologist|Pediatrician|General Physician|Cardiologist)/i) || [,''])[1];
24
+ const experience = (text.match(/(\\d+\\s+Years? Experience)/i) || [,''])[1];
25
+ const fee = (text.match(/(?:₹|Rs\\.?\\s*)([0-9,]+)/i) || [,''])[1];
26
+ return { name: title, specialty, experience, fee: fee ? Number(fee.replace(/,/g, '')) : '', profile_url: location.href };
27
+ })()`));
28
+ if (!row?.name) throw new EmptyResultError('practo profile');
29
+ return [row];
30
+ },
31
+ });
@@ -0,0 +1,30 @@
1
+ import { EmptyResultError } from '@agentrhq/webcmd/errors';
2
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
+ import { buildSearchUrl, normalizeLimit, rowsFromSearchState, unwrapBrowserResult } from './utils.js';
4
+
5
+ cli({
6
+ site: 'practo',
7
+ name: 'search',
8
+ access: 'read',
9
+ description: 'Search Practo doctors by specialty, city, and optional locality',
10
+ domain: 'www.practo.com',
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ args: [
15
+ { name: 'specialty', positional: true, required: true, help: 'Doctor specialty, e.g. orthopedist or dermatologist' },
16
+ { name: 'city', default: 'bangalore', help: 'City, e.g. bangalore' },
17
+ { name: 'locality', help: 'Optional locality, e.g. indiranagar' },
18
+ { name: 'limit', type: 'int', default: 10, help: 'Max doctors to return (1-25)' },
19
+ ],
20
+ columns: ['rank', 'practice_doctor_id', 'doctor_id', 'practice_id', 'name', 'specialty', 'experience_years', 'locality', 'clinic', 'fee', 'next_available', 'profile_url'],
21
+ func: async (page, kwargs) => {
22
+ const limit = normalizeLimit(kwargs.limit);
23
+ await page.goto(buildSearchUrl(kwargs));
24
+ await page.wait(2);
25
+ const state = unwrapBrowserResult(await page.evaluate(`(() => window.__REDUX_STATE__ || window.__PRELOADED_STATE__ || null)()`));
26
+ const rows = rowsFromSearchState(state, limit);
27
+ if (rows.length === 0) throw new EmptyResultError('practo search', 'No doctor cards were found in Practo page state.');
28
+ return rows;
29
+ },
30
+ });
@@ -0,0 +1,19 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { normalizeLimit, rowsForSlots } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'slots',
7
+ access: 'read',
8
+ description: 'List available Practo appointment slots for a practice_doctor_id',
9
+ domain: 'www.practo.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ { name: 'practice_doctor_id', positional: true, required: true, help: 'Practo practice_doctor_id from search results' },
15
+ { name: 'limit', type: 'int', default: 20, help: 'Max slots to return (1-25)' },
16
+ ],
17
+ columns: ['practice_doctor_id', 'time', 'available', 'amount', 'prepaid', 'appointment_token'],
18
+ func: async (page, kwargs) => (await rowsForSlots(page, kwargs.practice_doctor_id)).filter((row) => row.available).slice(0, normalizeLimit(kwargs.limit, 20)),
19
+ });
@@ -0,0 +1,374 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
2
+
3
+ const SLOT_TIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
4
+ export const PRACTO = 'https://www.practo.com';
5
+ export const DRIVE = 'https://drive.practo.com';
6
+
7
+ export function slugifyPathPart(value, label) {
8
+ const out = String(value ?? '').trim().toLowerCase()
9
+ .replace(/&/g, ' and ')
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/^-+|-+$/g, '');
12
+ if (!out) throw new ArgumentError(`${label} is required`);
13
+ return out;
14
+ }
15
+
16
+ export function normalizeLimit(value, defaultValue = 10, max = 25) {
17
+ const n = Number(value ?? defaultValue);
18
+ if (!Number.isInteger(n) || n < 1 || n > max) {
19
+ throw new ArgumentError(`limit must be an integer from 1 to ${max}`);
20
+ }
21
+ return n;
22
+ }
23
+
24
+ export function normalizePracticeDoctorId(value) {
25
+ const id = String(value ?? '').trim();
26
+ if (!/^\d+$/.test(id)) {
27
+ throw new ArgumentError('practice_doctor_id must be numeric', 'Use `webcmd practo search ...` or `webcmd practo slots <practice_doctor_id>` first.');
28
+ }
29
+ return id;
30
+ }
31
+
32
+ export function normalizeAppointmentId(value) {
33
+ const id = String(value ?? '').trim();
34
+ if (!id) throw new ArgumentError('appointment_id is required');
35
+ return id;
36
+ }
37
+
38
+ export function normalizeSlotTime(value) {
39
+ const raw = String(value ?? '').trim();
40
+ if (!SLOT_TIME_RE.test(raw)) {
41
+ throw new ArgumentError('time must be YYYY-MM-DD HH:mm:ss', 'Example: --time "2026-07-10 10:30:00"');
42
+ }
43
+ return raw;
44
+ }
45
+
46
+ export function normalizeConfirm(value) {
47
+ return value === true || value === 'true';
48
+ }
49
+
50
+ export function unwrapBrowserResult(value) {
51
+ if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'data') && Object.prototype.hasOwnProperty.call(value, 'session')) {
52
+ return value.data;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ export async function evalJson(page, url, init = {}) {
58
+ const result = unwrapBrowserResult(await page.evaluate(`(async () => {
59
+ try {
60
+ const res = await fetch(${JSON.stringify(url)}, {
61
+ credentials: 'include',
62
+ method: ${JSON.stringify(init.method || 'GET')},
63
+ headers: ${JSON.stringify(init.headers || { Accept: 'application/json' })},
64
+ body: ${init.body == null ? 'undefined' : JSON.stringify(init.body)}
65
+ });
66
+ const text = await res.text();
67
+ let data = null;
68
+ try { data = text ? JSON.parse(text) : null; } catch { data = { text }; }
69
+ if (!res.ok) return { __error: 'HTTP ' + res.status, status: res.status, data };
70
+ return data;
71
+ } catch (e) {
72
+ return { __error: String(e && e.message || e) };
73
+ }
74
+ })()`));
75
+ return assertJsonOk(result, url);
76
+ }
77
+
78
+ export async function practoJson(page, pathOrUrl) {
79
+ await page.goto(PRACTO);
80
+ return evalJson(page, pathOrUrl.startsWith('http') ? pathOrUrl : `${PRACTO}${pathOrUrl}`);
81
+ }
82
+
83
+ export async function driveJson(page, path, init = {}) {
84
+ await page.goto(`${DRIVE}/appointments`);
85
+ return evalJson(page, `${DRIVE}${path}`, init);
86
+ }
87
+
88
+ function displayName(user) {
89
+ return String(user?.name || user?.user?.name || user?.profile?.name || user?.logged_in_user?.name || '').trim();
90
+ }
91
+
92
+ export async function probeIdentity(page) {
93
+ let user;
94
+ try {
95
+ user = await practoJson(page, '/logged_in_user');
96
+ } catch (error) {
97
+ if (/HTTP 401|HTTP 403/.test(error?.message || '')) {
98
+ throw new AuthRequiredError('www.practo.com', 'Practo /logged_in_user rejected the current browser session');
99
+ }
100
+ throw error;
101
+ }
102
+ if (!user || user.__error) throw new AuthRequiredError('www.practo.com');
103
+ return [{ logged_in: true, site: 'practo', name: displayName(user) }];
104
+ }
105
+
106
+ export function buildSearchUrl({ city, specialty, locality }) {
107
+ const parts = [
108
+ 'https://www.practo.com',
109
+ slugifyPathPart(city, 'city'),
110
+ slugifyPathPart(specialty, 'specialty'),
111
+ ];
112
+ if (String(locality ?? '').trim()) parts.push(slugifyPathPart(locality, 'locality'));
113
+ return parts.join('/');
114
+ }
115
+
116
+ export function buildSlotsUrl(practiceDoctorId) {
117
+ return `https://www.practo.com/health/api/practicedoctors/${normalizePracticeDoctorId(practiceDoctorId)}/slots?mobile=true&group_by_hour=true&logged_in_api=false&first_available=true&`;
118
+ }
119
+
120
+ export async function rowsForSlots(page, practiceDoctorId) {
121
+ const payload = await practoJson(page, buildSlotsUrl(practiceDoctorId));
122
+ const rows = rowsFromSlotsPayload(payload);
123
+ if (rows.length === 0) throw new EmptyResultError('practo slots', 'No available slots were returned for that practice_doctor_id.');
124
+ return rows;
125
+ }
126
+
127
+ function slugFromProfileUrl(profileUrl) {
128
+ const raw = text(profileUrl);
129
+ if (!raw) return 'doctor';
130
+ try {
131
+ const url = new URL(raw, 'https://www.practo.com');
132
+ const parts = url.pathname.split('/').filter(Boolean);
133
+ const doctorIndex = parts.indexOf('doctor');
134
+ return doctorIndex >= 0 && parts[doctorIndex + 1] ? parts[doctorIndex + 1] : 'doctor';
135
+ } catch {
136
+ return 'doctor';
137
+ }
138
+ }
139
+
140
+ export function buildBookingUrl({ practiceDoctorId, slotTime, appointmentToken = '', amount = '', prepaid = false, profileUrl = '' }) {
141
+ const params = new URLSearchParams();
142
+ params.set('type', 'abs');
143
+ params.set('doctor_id', normalizePracticeDoctorId(practiceDoctorId));
144
+ params.set('appointment_time', normalizeSlotTime(slotTime));
145
+ if (appointmentToken) params.set('appointment_token', String(appointmentToken));
146
+ params.set('prepaid', String(Boolean(prepaid)));
147
+ if (amount !== '' && amount != null) params.set('amount', String(amount));
148
+ return `https://www.practo.com/appointment/${slugFromProfileUrl(profileUrl)}/${practiceDoctorId}/book?${params.toString()}`;
149
+ }
150
+
151
+ export function bookingPreviewFromPageData(data, context) {
152
+ const text = String(data?.text || '');
153
+ const confirmText = data?.confirmText || '';
154
+ const paymentMode = context.paymentMode || data?.paymentMode || (/pay at clinic/i.test(text) ? 'Pay At Clinic' : '');
155
+ return [{
156
+ practice_doctor_id: context.practiceDoctorId,
157
+ time: context.slotTime,
158
+ amount: context.amount,
159
+ prepaid: context.prepaid,
160
+ payment_mode: paymentMode,
161
+ requires_payment: Boolean(paymentMode && !/pay at clinic/i.test(paymentMode)),
162
+ confirm_button: confirmText,
163
+ booking_url: context.url,
164
+ }];
165
+ }
166
+
167
+ export async function prepareBookingPage(page, kwargs) {
168
+ const practiceDoctorId = normalizePracticeDoctorId(kwargs.practice_doctor_id ?? kwargs.practiceDoctorId);
169
+ const slotTime = normalizeSlotTime(kwargs.time);
170
+ const slots = await rowsForSlots(page, practiceDoctorId);
171
+ const slot = findSlot(slots, slotTime);
172
+ let paymentMode = '';
173
+ try {
174
+ const summary = await practoJson(page, `/health/api/appointment/payment/summary?appointment_type=abs&platform=web&practice_doctor_id=${practiceDoctorId}`);
175
+ const modes = summary?.payment_mode_details || summary?.data?.payment_mode_details || [];
176
+ paymentMode = String(modes?.[0]?.mode || modes?.[0]?.payment_mode || '').trim();
177
+ } catch {
178
+ paymentMode = '';
179
+ }
180
+ const url = buildBookingUrl({
181
+ practiceDoctorId,
182
+ slotTime,
183
+ appointmentToken: slot.appointment_token,
184
+ amount: slot.amount,
185
+ prepaid: slot.prepaid,
186
+ profileUrl: kwargs['profile-url'] ?? kwargs.profile_url ?? kwargs.profileUrl,
187
+ });
188
+ await page.goto(url);
189
+ let ready = false;
190
+ for (let i = 0; i < 60; i++) {
191
+ const readyNow = unwrapBrowserResult(await page.evaluate(`(() => /Confirm Clinic Visit|Enter your mobile number|sign in|login/i.test(document.body?.innerText || ''))()`));
192
+ if (readyNow) {
193
+ ready = true;
194
+ break;
195
+ }
196
+ await page.wait(0.5);
197
+ }
198
+ const data = unwrapBrowserResult(await page.evaluate(`(() => {
199
+ const text = document.body?.innerText || '';
200
+ const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]'))
201
+ .map((el) => (el.innerText || el.value || el.textContent || '').replace(/\\s+/g, ' ').trim())
202
+ .filter(Boolean);
203
+ return {
204
+ url: location.href,
205
+ text,
206
+ buttons,
207
+ confirmText: buttons.find((label) => /confirm clinic visit|confirm/i.test(label)) || '',
208
+ paymentMode: (/pay at clinic/i.test(text) && 'Pay At Clinic') || (/pay online/i.test(text) && 'Pay Online') || ''
209
+ };
210
+ })()`));
211
+ if (/enter your mobile number|login|sign in/i.test(String(data?.text || ''))) {
212
+ throw new AuthRequiredError('www.practo.com', 'Booking page requires a logged-in Practo session');
213
+ }
214
+ if (!ready || !data?.confirmText) {
215
+ throw new CommandExecutionError(
216
+ `Practo booking page did not expose a confirm button at ${data?.url || 'unknown URL'}`,
217
+ 'Pass --profile-url from `webcmd practo search ...` so the adapter can build Practo\'s canonical booking URL.',
218
+ );
219
+ }
220
+ return { slot, data, context: { practiceDoctorId, slotTime, amount: slot.amount, prepaid: slot.prepaid, paymentMode, url } };
221
+ }
222
+
223
+ export async function submitBookingPage(page) {
224
+ const result = unwrapBrowserResult(await page.evaluate(`(async () => {
225
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
226
+ const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]'));
227
+ const button = buttons.find((el) => /confirm clinic visit|confirm/i.test(el.innerText || el.value || el.textContent || ''));
228
+ if (!button) return { ok: false, message: 'Confirm Clinic Visit button not found' };
229
+ button.click();
230
+ await sleep(3000);
231
+ return { ok: true, url: location.href, text: document.body?.innerText || '' };
232
+ })()`));
233
+ if (!result?.ok) throw new CommandExecutionError(result?.message || 'Failed to click Practo confirmation button');
234
+ return result;
235
+ }
236
+
237
+ export async function cancelAppointment(page, appointmentId) {
238
+ await page.goto(`${DRIVE}/appointments`);
239
+ const result = unwrapBrowserResult(await page.evaluate(`(async () => {
240
+ const token = decodeURIComponent((document.cookie.split('; ').find((c) => c.startsWith('X-Genesis-Token=')) || '').split('=')[1] || '');
241
+ if (!token) return { ok: false, message: 'X-Genesis-Token cookie missing' };
242
+ const res = await fetch(${JSON.stringify(`${DRIVE}/api/record/v2/cancel_appointment/${appointmentId}`)}, {
243
+ method: 'PATCH',
244
+ credentials: 'include',
245
+ headers: { 'Accept': 'application/json', 'X-Genesis-Token': token }
246
+ });
247
+ const text = await res.text();
248
+ return { ok: res.ok, status: res.status, text };
249
+ })()`));
250
+ if (!result?.ok) {
251
+ if (result?.status === 401 || result?.status === 403) throw new AuthRequiredError('drive.practo.com', `Practo Drive cancel returned HTTP ${result.status}`);
252
+ throw new CommandExecutionError(`Practo cancel failed: ${result?.message || result?.status || 'unknown'}`);
253
+ }
254
+ }
255
+
256
+ function text(value) {
257
+ if (value == null) return '';
258
+ if (typeof value === 'object') {
259
+ return text(firstDefined(value.name, value.practice_name, value.establishment_name, value.clinic_name, value.locality));
260
+ }
261
+ return String(value).replace(/\s+/g, ' ').trim();
262
+ }
263
+
264
+ function firstDefined(...values) {
265
+ return values.find((value) => value !== undefined && value !== null && value !== '');
266
+ }
267
+
268
+ function asAbsPractoUrl(value) {
269
+ const raw = text(value);
270
+ if (!raw) return '';
271
+ try {
272
+ return new URL(raw, 'https://www.practo.com').toString();
273
+ } catch {
274
+ return raw;
275
+ }
276
+ }
277
+
278
+ function doctorsFromState(state) {
279
+ const doctors = state?.listingV2?.doctors?.entities ?? state?.doctors?.entities ?? state?.doctors;
280
+ if (Array.isArray(doctors)) return doctors;
281
+ if (doctors && typeof doctors === 'object') return Object.values(doctors);
282
+ return [];
283
+ }
284
+
285
+ export function rowsFromSearchState(state, limit = 10) {
286
+ return doctorsFromState(state).slice(0, limit).map((doctor, i) => {
287
+ const practiceDoctorId = firstDefined(doctor.practice_doctor_id, doctor.relation_id, doctor.id);
288
+ const name = firstDefined(doctor.doctor_name, doctor.name, doctor.display_name);
289
+ if (!practiceDoctorId || !name) return null;
290
+ return {
291
+ rank: i + 1,
292
+ practice_doctor_id: String(practiceDoctorId),
293
+ doctor_id: text(firstDefined(doctor.doctor_id, doctor.provider_id)),
294
+ practice_id: text(firstDefined(doctor.practice_id, doctor.establishment_id)),
295
+ name: text(name),
296
+ specialty: text(firstDefined(doctor.specialization, doctor.speciality, doctor.specialty)),
297
+ experience_years: firstDefined(doctor.experience_years, doctor.experience),
298
+ locality: text(firstDefined(doctor.locality, doctor.locality_name)),
299
+ city: text(doctor.city),
300
+ clinic: text(firstDefined(doctor.practice, doctor.practice_name, doctor.clinic_name)),
301
+ fee: firstDefined(doctor.consultation_fees, doctor.fees, doctor.fee),
302
+ next_available: text(firstDefined(doctor.next_available_timestamp, doctor.next_available, doctor.first_available_day_slots?.[0]?.time)),
303
+ profile_url: asAbsPractoUrl(firstDefined(doctor.profile_url, doctor.url, doctor.translated_new_slug)),
304
+ };
305
+ }).filter(Boolean);
306
+ }
307
+
308
+ function flattenSlots(value, out = []) {
309
+ if (!value) return out;
310
+ if (Array.isArray(value)) {
311
+ for (const item of value) flattenSlots(item, out);
312
+ return out;
313
+ }
314
+ if (typeof value !== 'object') return out;
315
+ const time = firstDefined(value.ts, value.start_time, value.appointment_time, value.slot_time);
316
+ if (time) out.push(value);
317
+ else if (value.time && !value.timeslots && !value.slots) out.push(value);
318
+ for (const key of ['timeslots', 'slots', 'items', 'available_slots', 'hour_slots']) {
319
+ if (value[key]) flattenSlots(value[key], out);
320
+ }
321
+ return out;
322
+ }
323
+
324
+ export function rowsFromSlotsPayload(payload) {
325
+ const data = payload?.data ?? payload;
326
+ const practiceDoctorId = text(firstDefined(data?.practice_doctor_id, data?.practiceDoctorId));
327
+ const amount = firstDefined(data?.amount, data?.appointment_payment_details?.amount, data?.fee);
328
+ const prepaid = Boolean(firstDefined(data?.prepaid, data?.appointment_payment_details?.prepaid, false));
329
+ const appointmentToken = text(firstDefined(data?.appointment_token, data?.appointmentToken));
330
+ return flattenSlots(data?.slots ?? data).map((slot) => ({
331
+ practice_doctor_id: text(firstDefined(slot.practice_doctor_id, practiceDoctorId)),
332
+ time: text(firstDefined(slot.ts, slot.time, slot.start_time, slot.appointment_time, slot.slot_time)),
333
+ available: firstDefined(slot.available, slot.is_available, true) !== false,
334
+ amount: firstDefined(slot.amount, amount),
335
+ prepaid: Boolean(firstDefined(slot.prepaid, prepaid)),
336
+ appointment_token: text(firstDefined(slot.appointment_token, appointmentToken)),
337
+ })).filter((row) => row.practice_doctor_id && row.time);
338
+ }
339
+
340
+ export function findSlot(slots, slotTime) {
341
+ const wanted = normalizeSlotTime(slotTime);
342
+ const slot = slots.find((row) => row.time === wanted);
343
+ if (!slot) {
344
+ throw new EmptyResultError('practo slots', `No Practo slot matched ${wanted}. Run \`webcmd practo slots <practice_doctor_id>\` first.`);
345
+ }
346
+ if (slot.available === false) {
347
+ throw new EmptyResultError('practo slots', `Practo slot ${wanted} is not available.`);
348
+ }
349
+ return slot;
350
+ }
351
+
352
+ export function assertJsonOk(result, label) {
353
+ if (!result || typeof result !== 'object') {
354
+ throw new CommandExecutionError(`${label} returned no data`);
355
+ }
356
+ if (result.__error) {
357
+ throw new CommandExecutionError(`${label} failed: ${result.__error}`);
358
+ }
359
+ return result;
360
+ }
361
+
362
+ export const __test__ = {
363
+ buildSearchUrl,
364
+ buildSlotsUrl,
365
+ buildBookingUrl,
366
+ normalizeConfirm,
367
+ normalizeLimit,
368
+ normalizePracticeDoctorId,
369
+ normalizeAppointmentId,
370
+ normalizeSlotTime,
371
+ rowsFromSearchState,
372
+ rowsFromSlotsPayload,
373
+ findSlot,
374
+ };
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { probeIdentity } from './utils.js';
3
+
4
+ cli({
5
+ site: 'practo',
6
+ name: 'whoami',
7
+ aliases: ['auth-status'],
8
+ access: 'read',
9
+ description: 'Show whether the current browser session is logged into Practo',
10
+ domain: 'www.practo.com',
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ siteSession: 'persistent',
15
+ args: [],
16
+ columns: ['logged_in', 'site', 'name'],
17
+ authStatus: {
18
+ quickCheck: async (page) => {
19
+ try {
20
+ const rows = await probeIdentity(page);
21
+ return { logged_in: true, name: rows[0].name };
22
+ } catch {
23
+ return { logged_in: false };
24
+ }
25
+ },
26
+ },
27
+ func: probeIdentity,
28
+ });