@mahe_pkm/buzl-capi 0.1.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.
@@ -0,0 +1,506 @@
1
+ /**
2
+ * HTML, Form & Site Location Scanner
3
+ * Scans directories recursively to discover HTML files, existing tags, form fields, and site location
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const DEFAULT_IGNORED_DIRS = [
9
+ 'node_modules',
10
+ '.git',
11
+ '.github',
12
+ 'Dev_CAPi',
13
+ 'dist',
14
+ 'build',
15
+ '.next',
16
+ '.nuxt',
17
+ 'vendor',
18
+ 'partials',
19
+ 'includes',
20
+ 'components'
21
+ ];
22
+
23
+ /**
24
+ * Recursively find all HTML files
25
+ */
26
+ function findHtmlFiles(dir, ignoredDirs = DEFAULT_IGNORED_DIRS) {
27
+ let results = [];
28
+ try {
29
+ const list = fs.readdirSync(dir);
30
+ for (const file of list) {
31
+ const fullPath = path.join(dir, file);
32
+ const stat = fs.statSync(fullPath);
33
+
34
+ if (stat && stat.isDirectory()) {
35
+ if (file === '.buzl' || file.startsWith('.buzl') || ignoredDirs.includes(file)) {
36
+ continue;
37
+ }
38
+ results = results.concat(findHtmlFiles(fullPath, ignoredDirs));
39
+ } else if (file.toLowerCase().endsWith('.html') || file.toLowerCase().endsWith('.htm')) {
40
+ results.push(fullPath);
41
+ }
42
+ }
43
+ } catch (err) {
44
+ console.error(`[Scanner] Error reading directory ${dir}:`, err.message);
45
+ }
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * Sanitize and normalize WhatsApp phone numbers to international digits format
51
+ */
52
+ function sanitizeWhatsappNumber(raw) {
53
+ if (!raw) return null;
54
+ const digits = String(raw).replace(/\D/g, '');
55
+ if (!digits) return null;
56
+ // If 10 digits starting with [6-9] (Indian mobile like 9591318811), prepend country code 91
57
+ if (digits.length === 10 && /^[6-9]/.test(digits)) {
58
+ return '91' + digits;
59
+ }
60
+ // If 11 digits starting with 0, replace 0 with 91
61
+ if (digits.length === 11 && digits.startsWith('0')) {
62
+ return '91' + digits.slice(1);
63
+ }
64
+ if (digits.length >= 10 && digits.length <= 15) {
65
+ return digits;
66
+ }
67
+ return digits;
68
+ }
69
+
70
+ /**
71
+ * Extract WhatsApp numbers from markup strings or attributes
72
+ */
73
+ function extractWhatsappFromText(text) {
74
+ if (!text) return null;
75
+ // 1. wa.me/919591318811
76
+ const waMeMatch = text.match(/wa\.me\/(\+?\d+)/i);
77
+ if (waMeMatch) return sanitizeWhatsappNumber(waMeMatch[1]);
78
+
79
+ // 2. api.whatsapp.com/send?phone=919591318811
80
+ const apiMatch = text.match(/api\.whatsapp\.com\/send\?[^"'>]*phone=(\+?\d+)/i);
81
+ if (apiMatch) return sanitizeWhatsappNumber(apiMatch[1]);
82
+
83
+ // 3. data-whatsapp="919591318811" or data-wa, data-phone, data-number, data-buzl-wa
84
+ const dataAttrMatch = text.match(/data-(?:whatsapp|wa|phone|number|buzl-wa)=["'](\+?\d+)["']/i);
85
+ if (dataAttrMatch) return sanitizeWhatsappNumber(dataAttrMatch[1]);
86
+
87
+ // 4. tel:+919591318811
88
+ const telMatch = text.match(/href=["']tel:(\+?\d+)["']/i);
89
+ if (telMatch) return sanitizeWhatsappNumber(telMatch[1]);
90
+
91
+ // 5. JS variable like WA_NUMBER = '919591318811'
92
+ const jsVarMatch = text.match(/(?:WA_NUMBER|waNumber|whatsapp_number)\s*=\s*["'](\+?\d+)["']/i);
93
+ if (jsVarMatch) return sanitizeWhatsappNumber(jsVarMatch[1]);
94
+
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Auto-detect site location from metadata or markup
100
+ */
101
+ function detectSiteLocation(rootDir, htmlContent = '') {
102
+ // 1. Check website_context.json
103
+ const ctxPath = path.join(rootDir, 'website_context.json');
104
+ if (fs.existsSync(ctxPath)) {
105
+ try {
106
+ const ctx = JSON.parse(fs.readFileSync(ctxPath, 'utf8'));
107
+ if (ctx.address) {
108
+ // Extract area and city from address (e.g. "Sahakar Nagar, Byatarayanapura, Bengaluru")
109
+ const parts = ctx.address.split(',').map(s => s.trim()).filter(s => !s.toLowerCase().includes('floor') && !/\d{6}/.test(s));
110
+ if (parts.length >= 2) {
111
+ return parts.slice(-3).join(', ');
112
+ }
113
+ return ctx.address;
114
+ }
115
+ if (ctx.location) return ctx.location;
116
+ if (ctx.city) return ctx.city;
117
+ } catch (e) {}
118
+ }
119
+
120
+ // 2. Check assets/data/business.json
121
+ const bizPath = path.join(rootDir, 'assets', 'data', 'business.json');
122
+ if (fs.existsSync(bizPath)) {
123
+ try {
124
+ const biz = JSON.parse(fs.readFileSync(bizPath, 'utf8'));
125
+ if (biz.location) return biz.location;
126
+ if (biz.address) {
127
+ if (biz.address.area && biz.address.city) return `${biz.address.area}, ${biz.address.city}`;
128
+ if (biz.address.city) return biz.address.city;
129
+ }
130
+ } catch (e) {}
131
+ }
132
+
133
+ // 3. Scan HTML for <address> or hero kicker text
134
+ if (htmlContent) {
135
+ const kickerM = htmlContent.match(/class=["'][^"']*(?:hero__kicker|kicker)[^"']*["'][^>]*>([\s\S]*?)<\/p>/i);
136
+ if (kickerM) {
137
+ const plain = kickerM[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
138
+ if (plain && plain.length < 100) return plain;
139
+ }
140
+ const addrM = htmlContent.match(/<address\b[^>]*>([\s\S]*?)<\/address>/i);
141
+ if (addrM) {
142
+ const plain = addrM[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
143
+ if (plain && plain.length < 120) return plain;
144
+ }
145
+ }
146
+
147
+ return '';
148
+ }
149
+
150
+ /**
151
+ * Analyze a single HTML file for head, body, forms, inputs in order
152
+ */
153
+ function analyzeHtmlFile(filePath, rootDir = null) {
154
+ const content = fs.readFileSync(filePath, 'utf8');
155
+ const relativePath = rootDir ? path.relative(rootDir, filePath).replace(/\\/g, '/') : path.basename(filePath);
156
+
157
+ const hasHead = /<head[\s>]/i.test(content);
158
+ const hasBody = /<body[\s>]/i.test(content);
159
+
160
+ // Check existing tracking tags
161
+ const hasGTM = /googletagmanager\.com/i.test(content) || /GTM-[A-Z0-9]+/i.test(content);
162
+ const hasMeta = /connect\.facebook\.net/i.test(content) || /fbq\(/i.test(content);
163
+ const hasBuzl = /buzl-tracking/i.test(content) || /BuzlTracker/i.test(content) || /BUZL_TRACKING_/i.test(content);
164
+
165
+ // Extract existing IDs if present
166
+ const gtmMatch = content.match(/GTM-[A-Z0-9]+/i);
167
+ const existingGtmId = gtmMatch ? gtmMatch[0] : null;
168
+
169
+ const metaMatch = content.match(/fbq\(\s*['"]init['"]\s*,\s*['"](\d+)['"]/i);
170
+ const existingMetaPixelId = metaMatch ? metaMatch[1] : null;
171
+
172
+ // Extract existing Buzl configuration if previously injected
173
+ let existingConfig = null;
174
+ const configMatch = content.match(/window\.__BUZL_CONFIG__\s*=\s*(\{[\s\S]*?\});/);
175
+ if (configMatch) {
176
+ try {
177
+ existingConfig = JSON.parse(configMatch[1]);
178
+ } catch (e) {}
179
+ }
180
+
181
+ // Detect forms and inputs in exact DOM order
182
+ const forms = [];
183
+ const formRegex = /<form\b([^>]*)>([\s\S]*?)<\/form>/gi;
184
+ let match;
185
+
186
+ while ((match = formRegex.exec(content)) !== null) {
187
+ const formAttrs = match[1] || '';
188
+ const formInner = match[2] || '';
189
+
190
+ const idMatch = formAttrs.match(/id=['"]([^'"]+)['"]/i);
191
+ const actionMatch = formAttrs.match(/action=['"]([^'"]+)['"]/i);
192
+ const formId = idMatch ? idMatch[1] : `form_${forms.length + 1}`;
193
+ const action = actionMatch ? actionMatch[1] : '';
194
+
195
+ // Extract all interactive form elements in DOM order: <input>, <select>, <textarea>
196
+ const inputs = [];
197
+ const elemRegex = /<(input|select|textarea)\b([^>]*)>/gi;
198
+ let elemMatch;
199
+
200
+ while ((elemMatch = elemRegex.exec(formInner)) !== null) {
201
+ const tag = elemMatch[1].toLowerCase();
202
+ const attrs = elemMatch[2];
203
+
204
+ const nameM = attrs.match(/name=['"]([^'"]+)['"]/i);
205
+ const typeM = attrs.match(/type=['"]([^'"]+)['"]/i);
206
+ const idM = attrs.match(/id=['"]([^'"]+)['"]/i);
207
+
208
+ const type = typeM ? typeM[1].toLowerCase() : (tag === 'select' ? 'select' : tag === 'textarea' ? 'textarea' : 'text');
209
+ if (['submit', 'button', 'reset', 'hidden'].includes(type)) continue;
210
+
211
+ const name = nameM ? nameM[1] : (idM ? idM[1] : '');
212
+ if (name) {
213
+ inputs.push({
214
+ tag,
215
+ name,
216
+ type,
217
+ id: idM ? idM[1] : ''
218
+ });
219
+ }
220
+ }
221
+
222
+ // Inspect form and its submit buttons for WhatsApp number
223
+ let detectedWhatsapp = extractWhatsappFromText(formAttrs);
224
+
225
+ if (!detectedWhatsapp) {
226
+ const btnRegex = /<(button|input|a)\b([^>]*)>([\s\S]*?)<\/\1>|<(input)\b([^>]*)>/gi;
227
+ let btnM;
228
+ while ((btnM = btnRegex.exec(formInner)) !== null) {
229
+ const btnAttrs = btnM[2] || btnM[5] || '';
230
+ const num = extractWhatsappFromText(btnAttrs);
231
+ if (num) {
232
+ detectedWhatsapp = num;
233
+ break;
234
+ }
235
+ if (btnAttrs.includes('type="hidden"') && /name=["'](?:whatsapp|wa|phone_to)["']/i.test(btnAttrs)) {
236
+ const valM = btnAttrs.match(/value=["'](\+?\d+)["']/i);
237
+ if (valM) {
238
+ detectedWhatsapp = sanitizeWhatsappNumber(valM[1]);
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ forms.push({
246
+ id: formId,
247
+ action: action,
248
+ inputCount: inputs.length,
249
+ inputs: inputs,
250
+ detectedWhatsapp: detectedWhatsapp || null
251
+ });
252
+ }
253
+
254
+ // Collect all WhatsApp numbers across the page (buttons, links, floating CTAs, script vars)
255
+ const detectedWhatsappNumbers = [];
256
+ const waRegex = /(?:wa\.me\/|api\.whatsapp\.com\/send\?[^"'>]*phone=|data-(?:whatsapp|wa|phone|buzl-wa)=["']|href=["']tel:)(\+?\d+)/gi;
257
+ let waM;
258
+ while ((waM = waRegex.exec(content)) !== null) {
259
+ const cleanNum = sanitizeWhatsappNumber(waM[1]);
260
+ if (cleanNum && !detectedWhatsappNumbers.includes(cleanNum)) {
261
+ detectedWhatsappNumbers.push(cleanNum);
262
+ }
263
+ }
264
+
265
+ const jsWaRegex = /(?:WA_NUMBER|waNumber|whatsapp_number)\s*=\s*["'](\+?\d+)["']/gi;
266
+ while ((waM = jsWaRegex.exec(content)) !== null) {
267
+ const cleanNum = sanitizeWhatsappNumber(waM[1]);
268
+ if (cleanNum && !detectedWhatsappNumbers.includes(cleanNum)) {
269
+ detectedWhatsappNumbers.push(cleanNum);
270
+ }
271
+ }
272
+
273
+ // If a form didn't have an explicit button number, associate the page's detected number as fallback
274
+ if (detectedWhatsappNumbers.length > 0) {
275
+ forms.forEach(f => {
276
+ if (!f.detectedWhatsapp) {
277
+ f.detectedWhatsapp = detectedWhatsappNumbers[0];
278
+ }
279
+ });
280
+ }
281
+
282
+ return {
283
+ filePath,
284
+ relativePath,
285
+ hasHead,
286
+ hasBody,
287
+ hasGTM,
288
+ existingGtmId,
289
+ hasMeta,
290
+ existingMetaPixelId,
291
+ existingConfig,
292
+ hasBuzl,
293
+ forms,
294
+ detectedWhatsappNumbers,
295
+ content
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Scan entire project directory
301
+ */
302
+ function scanProject(rootDir) {
303
+ const htmlFiles = findHtmlFiles(rootDir);
304
+ const analyzedFiles = htmlFiles.map(f => analyzeHtmlFile(f, rootDir));
305
+
306
+ const totalForms = analyzedFiles.reduce((acc, f) => acc + f.forms.length, 0);
307
+ const filesWithGtm = analyzedFiles.filter(f => f.hasGTM).length;
308
+ const filesWithMeta = analyzedFiles.filter(f => f.hasMeta).length;
309
+
310
+ // Collect unique form fields in order of discovery
311
+ const uniqueFields = [];
312
+ const fieldSet = new Set();
313
+
314
+ analyzedFiles.forEach(f => {
315
+ f.forms.forEach(form => {
316
+ form.inputs.forEach(input => {
317
+ const lowerName = input.name.toLowerCase();
318
+ if (!fieldSet.has(lowerName)) {
319
+ fieldSet.add(lowerName);
320
+ uniqueFields.push({
321
+ name: input.name,
322
+ type: input.type,
323
+ tag: input.tag,
324
+ isCore: ['name', 'phone', 'location', 'bizname', 'bizphone', 'bizlocation'].includes(lowerName)
325
+ });
326
+ }
327
+ });
328
+ });
329
+ });
330
+
331
+ // Detect site location from first HTML file or metadata
332
+ const firstHtmlContent = analyzedFiles.length > 0 ? analyzedFiles[0].content : '';
333
+ const detectedLocation = detectSiteLocation(rootDir, firstHtmlContent);
334
+
335
+ // Strip large content strings from final return
336
+ const cleanFiles = analyzedFiles.map(({ content, ...rest }) => rest);
337
+
338
+ // Extract first found existingConfig
339
+ const existingConfig = (analyzedFiles.find(f => f.existingConfig && Object.keys(f.existingConfig).length > 0) || {}).existingConfig || null;
340
+
341
+ // Compute live state of the site
342
+ const { listBackups } = require('./rollback');
343
+ const backups = listBackups(rootDir);
344
+
345
+ const gtmId = analyzedFiles.find(f => f.existingGtmId)?.existingGtmId || (existingConfig && existingConfig.gtmId) || null;
346
+ const metaPixelId = analyzedFiles.find(f => f.existingMetaPixelId)?.existingMetaPixelId || (existingConfig && existingConfig.metaPixelId) || null;
347
+
348
+ const capiCfg = existingConfig && existingConfig.buzlCapi;
349
+ const hasCapi = !!(capiCfg && capiCfg.endpoint && capiCfg.authUser);
350
+
351
+ const sheetUrl = existingConfig && existingConfig.googleSheetUrl;
352
+ const hasSheets = !!(sheetUrl && sheetUrl.trim().length > 0);
353
+
354
+ const zohoCfg = existingConfig && existingConfig.zoho;
355
+ const hasZoho = !!(zohoCfg && zohoCfg.xnQsjsdp && zohoCfg.xnQsjsdp.trim().length > 0);
356
+
357
+ const waCfg = existingConfig && existingConfig.whatsapp;
358
+ const hasWhatsapp = !!(waCfg && waCfg.number);
359
+
360
+ // Aggregate detected WhatsApp numbers across all forms & page buttons
361
+ const waCountMap = new Map();
362
+ analyzedFiles.forEach(f => {
363
+ f.forms.forEach(form => {
364
+ if (form.detectedWhatsapp) {
365
+ waCountMap.set(form.detectedWhatsapp, (waCountMap.get(form.detectedWhatsapp) || 0) + 1);
366
+ }
367
+ });
368
+ (f.detectedWhatsappNumbers || []).forEach(num => {
369
+ waCountMap.set(num, (waCountMap.get(num) || 0) + 1);
370
+ });
371
+ });
372
+
373
+ let detectedWhatsapp = null;
374
+ let maxCount = 0;
375
+ for (const [num, count] of waCountMap.entries()) {
376
+ if (count > maxCount) {
377
+ maxCount = count;
378
+ detectedWhatsapp = num;
379
+ }
380
+ }
381
+
382
+ // Collect all forms across files with detailed identifiers & archetype grouping
383
+ const allDiscoveredForms = [];
384
+ const formSignatureMap = new Map();
385
+
386
+ analyzedFiles.forEach(f => {
387
+ f.forms.forEach((form, idx) => {
388
+ const selector = form.id.startsWith('form_') ? `form:nth-of-type(${idx + 1})` : `#${form.id}`;
389
+ const inputNames = form.inputs.map(i => i.name).sort().join('|');
390
+ const signature = `${form.id}::${inputNames}`;
391
+
392
+ if (!formSignatureMap.has(signature)) {
393
+ formSignatureMap.set(signature, {
394
+ formId: form.id,
395
+ selector: selector,
396
+ action: form.action,
397
+ inputs: form.inputs,
398
+ inputCount: form.inputs.length,
399
+ detectedWhatsapp: form.detectedWhatsapp || detectedWhatsapp || null,
400
+ pages: [f.relativePath],
401
+ isShared: false
402
+ });
403
+ } else {
404
+ const existing = formSignatureMap.get(signature);
405
+ if (!existing.pages.includes(f.relativePath)) {
406
+ existing.pages.push(f.relativePath);
407
+ existing.isShared = true;
408
+ }
409
+ if (!existing.detectedWhatsapp && form.detectedWhatsapp) {
410
+ existing.detectedWhatsapp = form.detectedWhatsapp;
411
+ }
412
+ }
413
+
414
+ allDiscoveredForms.push({
415
+ formId: form.id,
416
+ file: f.relativePath,
417
+ selector: selector,
418
+ action: form.action,
419
+ inputs: form.inputs,
420
+ inputCount: form.inputs.length,
421
+ detectedWhatsapp: form.detectedWhatsapp || detectedWhatsapp || null
422
+ });
423
+ });
424
+ });
425
+
426
+ const formArchetypes = Array.from(formSignatureMap.values());
427
+ const directories = Array.from(new Set(cleanFiles.map(f => {
428
+ const dir = path.dirname(f.relativePath).replace(/\\/g, '/');
429
+ return dir === '.' ? '/' : `/${dir}`;
430
+ }))).sort();
431
+
432
+ const cleanDirName = path.basename(rootDir).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
433
+ const detectedDomain = cleanDirName || 'landing-page';
434
+
435
+ let detectedService = '';
436
+ for (const form of allDiscoveredForms) {
437
+ const sInput = (form.inputs || []).find(i => {
438
+ const lower = (i.name || '').toLowerCase();
439
+ return lower.includes('service') || lower.includes('inquiry') || lower.includes('treatment') || lower.includes('course') || lower.includes('department');
440
+ });
441
+ if (sInput) {
442
+ detectedService = form.id || form.selector || 'Website Inquiry';
443
+ break;
444
+ }
445
+ }
446
+ if (!detectedService && allDiscoveredForms.length > 0) {
447
+ detectedService = allDiscoveredForms[0].id || 'General Inquiry';
448
+ }
449
+
450
+ const liveState = {
451
+ gtm: { active: !!gtmId, id: gtmId },
452
+ meta: { active: !!metaPixelId, id: metaPixelId },
453
+ buzlCapi: {
454
+ active: hasCapi,
455
+ endpoint: (capiCfg && capiCfg.endpoint) || '',
456
+ authUser: (capiCfg && capiCfg.authUser) || ''
457
+ },
458
+ googleSheets: { active: hasSheets, url: sheetUrl || '' },
459
+ zoho: { active: hasZoho, endpoint: (zohoCfg && zohoCfg.endpoint) || '', xnQsjsdp: (zohoCfg && zohoCfg.xnQsjsdp) || '' },
460
+ whatsapp: {
461
+ active: hasWhatsapp,
462
+ number: (waCfg && waCfg.number) || '',
463
+ detectedNumber: detectedWhatsapp || ''
464
+ },
465
+ detectedLocation: detectedLocation || '',
466
+ detectedDomain: detectedDomain,
467
+ detectedService: detectedService || 'General Inquiry',
468
+ forms: allDiscoveredForms,
469
+ formArchetypes: formArchetypes,
470
+ directories: directories,
471
+ backups: backups.map(b => ({
472
+ dirName: b.dirName,
473
+ name: b.name,
474
+ timestamp: b.timestamp,
475
+ filesCount: b.filesCount
476
+ }))
477
+ };
478
+
479
+ return {
480
+ rootDir,
481
+ totalHtmlFiles: htmlFiles.length,
482
+ files: cleanFiles,
483
+ totalForms,
484
+ totalUniqueForms: formArchetypes.length,
485
+ directories,
486
+ filesWithGtm,
487
+ filesWithMeta,
488
+ uniqueFields,
489
+ detectedLocation: detectedLocation || '',
490
+ detectedWhatsapp,
491
+ detectedDomain,
492
+ detectedService: detectedService || 'General Inquiry',
493
+ existingConfig,
494
+ liveState,
495
+ forms: allDiscoveredForms,
496
+ formArchetypes: formArchetypes,
497
+ backups: liveState.backups
498
+ };
499
+ }
500
+
501
+ module.exports = {
502
+ findHtmlFiles,
503
+ analyzeHtmlFile,
504
+ detectSiteLocation,
505
+ scanProject
506
+ };