1import { Actor, log } from 'apify';
2import { ApifyClient } from 'apify-client';
3
4await Actor.init();
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24const GOOGLE_MAPS_ACTOR = 'compass/crawler-google-places';
25const SUB_ACTOR_TIMEOUT_SECS = 300;
26const SUB_ACTOR_WAIT_SECS = 320;
27
28const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN });
29
30async function run() {
31
32
33
34const input = await Actor.getInput();
35const {
36 businessName,
37 city,
38 state = '',
39 maxCompetitors = 3,
40} = input ?? {};
41
42if (!businessName || !city) {
43 log.error('Input validation failed: missing businessName or city.');
44 await Actor.pushData({
45 businessName: businessName ?? null,
46 city: city ?? null,
47 error: 'Input must include both businessName and city. Please provide a non-empty value for each.',
48 });
49 await Actor.exit();
50 return;
51}
52
53const searchString = [businessName, city, state].filter(Boolean).join(' ');
54const locationQuery = [city, state].filter(Boolean).join(', ');
55
56
57
58
59
60
61
62
63
64
65
66
67
68const telemetryUrl = process.env.TELEMETRY_WEBHOOK_URL;
69const telemetrySecret = process.env.TELEMETRY_WEBHOOK_SECRET;
70
71if (telemetryUrl && telemetrySecret) {
72 try {
73 await Actor.addWebhook({
74 eventTypes: ['ACTOR.RUN.TIMED_OUT', 'ACTOR.RUN.FAILED'],
75 requestUrl: telemetryUrl,
76 idempotencyKey: Actor.getEnv().actorRunId ?? undefined,
77
78 payloadTemplate: `{
79 "eventType": {{eventType}},
80 "createdAt": {{createdAt}},
81 "resource": {{resource}},
82 "diagnostics": {
83 "location": ${JSON.stringify(locationQuery || city)},
84 "stage": "audit"
85 }
86 }`,
87 headersTemplate: `{ "Authorization": ${JSON.stringify(`Bearer ${telemetrySecret}`)} }`,
88 });
89 log.info('Failure telemetry webhook registered.');
90 } catch (err) {
91 log.warning(`Could not register telemetry webhook: ${err.message}`);
92 }
93}
94
95
96
97log.info(`Searching Google Maps for: "${searchString}"`);
98
99
100
101
102async function searchPlaces(query) {
103 const subRun = await Actor.call(
104 GOOGLE_MAPS_ACTOR,
105 {
106 searchStringsArray: [query],
107 locationQuery,
108 language: 'en',
109 maxCrawledPlacesPerSearch: maxCompetitors + 5,
110 maxReviews: 0,
111 maxImages: 0,
112 scrapeReviewerPhotos: false,
113 scrapeReviewerUrl: false,
114 },
115 { timeout: SUB_ACTOR_TIMEOUT_SECS, waitSecs: SUB_ACTOR_WAIT_SECS },
116 );
117
118
119
120
121
122
123 if (subRun.status !== 'SUCCEEDED') {
124 if (subRun.status === 'RUNNING' || subRun.status === 'READY') {
125 try {
126 await apifyClient.run(subRun.id).abort();
127 log.warning(`Aborted unfinished sub-run ${subRun.id} after ${SUB_ACTOR_WAIT_SECS}s.`);
128 } catch (abortErr) {
129 log.warning(`Could not abort sub-run ${subRun.id}: ${abortErr.message}`);
130 }
131 }
132 throw new Error(
133 `Google Maps search did not complete within ${SUB_ACTOR_TIMEOUT_SECS}s (status: ${subRun.status}).`,
134 );
135 }
136
137 const { items } = await apifyClient.dataset(subRun.defaultDatasetId).listItems({ limit: 50 });
138 return items ?? [];
139}
140
141let items;
142try {
143 items = await searchPlaces(searchString);
144 log.info(`Dataset returned ${items.length} items.`);
145} catch (err) {
146 log.error(`Google Maps sub-actor failed: ${err.message}`);
147 await Actor.pushData({
148 businessName,
149 city,
150 error: `Google Maps scraper unavailable — please retry. Detail: ${err.message}`,
151 });
152 await Actor.exit();
153 return;
154}
155
156if (!items || items.length === 0) {
157 await Actor.pushData({
158 businessName,
159 city,
160 error: 'No results found. Try a more specific business name or verify the city.',
161 });
162 await Actor.exit();
163 return;
164}
165
166
167
168
169
170const normalised = businessName.toLowerCase().trim();
171const target =
172 items.find((p) => p.title?.toLowerCase().includes(normalised)) ?? items[0];
173
174
175
176
177
178
179function normaliseTitle(title) {
180 return (title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
181}
182
183function domainOf(website) {
184 if (!website) return null;
185 try {
186 return new URL(website).hostname.replace(/^www\./, '').toLowerCase();
187 } catch {
188 return null;
189 }
190}
191
192function isSameBrand(a, b) {
193 const domainA = domainOf(a.website);
194 const domainB = domainOf(b.website);
195 if (domainA && domainB && domainA === domainB) return true;
196
197 const titleA = normaliseTitle(a.title);
198 const titleB = normaliseTitle(b.title);
199 if (!titleA || !titleB) return false;
200 return titleA === titleB || titleA.startsWith(titleB) || titleB.startsWith(titleA);
201}
202
203const rivals = items.filter(
204 (p) => p.placeId !== target.placeId && !isSameBrand(p, target)
205);
206
207
208
209
210
211const targetCategory = target.categoryName ?? target.categories?.[0] ?? null;
212
213if (rivals.length < maxCompetitors && targetCategory) {
214 log.info(
215 `Brand search returned ${rivals.length} competitor(s) — searching category "${targetCategory}" instead.`
216 );
217 try {
218 const categoryItems = await searchPlaces(
219 [targetCategory, 'in', city, state].filter(Boolean).join(' '),
220 );
221
222 const seen = new Set([target.placeId, ...rivals.map((r) => r.placeId)]);
223 for (const place of categoryItems) {
224 if (rivals.length >= maxCompetitors) break;
225 if (seen.has(place.placeId) || isSameBrand(place, target)) continue;
226 seen.add(place.placeId);
227 rivals.push(place);
228 }
229 log.info(`Category search brought competitor count to ${rivals.length}.`);
230 } catch (err) {
231
232 log.warning(
233 `Category competitor search failed: ${err.message}. Continuing with ${rivals.length} competitor(s).`
234 );
235 }
236} else if (rivals.length < maxCompetitors) {
237 log.warning('Target profile has no category — cannot search for competitors.');
238}
239
240const competitors = rivals.slice(0, maxCompetitors);
241
242
243
244
245
246
247
248
249
250
251function scoreProfile(place) {
252 let score = 0;
253 const issues = [];
254 const recommendations = [];
255 const manualChecks = [];
256
257
258 if (place.phone) {
259 score += 10;
260 } else {
261 issues.push({
262 severity: 'high',
263 field: 'phone',
264 message: 'No phone number detected on Google Maps listing.',
265 });
266 recommendations.push('Add your phone number in Google Business Profile → Info tab.');
267 }
268
269
270 if (place.website) {
271 score += 15;
272 } else {
273 issues.push({
274 severity: 'high',
275 field: 'website',
276 message: 'No website linked to this listing.',
277 });
278 recommendations.push(
279 'Link your website in Google Business Profile → Info. ' +
280 'A missing website significantly hurts local search ranking.'
281 );
282 }
283
284
285 const rating = place.totalScore ?? 0;
286 if (rating >= 4.5) {
287 score += 15;
288 } else if (rating >= 4.0) {
289 score += 10;
290 } else if (rating >= 3.5) {
291 score += 5;
292 issues.push({
293 severity: 'medium',
294 field: 'rating',
295 message: `Rating is ${rating.toFixed(1)} — below 4.0 reduces click-through meaningfully.`,
296 });
297 recommendations.push(
298 'Systematically ask satisfied customers for reviews to raise your average.'
299 );
300 } else if (rating > 0) {
301 issues.push({
302 severity: 'high',
303 field: 'rating',
304 message: `Rating is ${rating.toFixed(1)} — below 3.5 significantly deters new customers.`,
305 });
306 recommendations.push(
307 'Respond professionally to all negative reviews. ' +
308 'Address the underlying issues and build a stream of new positive reviews.'
309 );
310 } else {
311 issues.push({
312 severity: 'high',
313 field: 'rating',
314 message: 'No rating found — business may have no reviews.',
315 });
316 }
317
318
319 const reviews = place.reviewsCount ?? 0;
320 if (reviews >= 100) {
321 score += 20;
322 } else if (reviews >= 50) {
323 score += 15;
324 } else if (reviews >= 20) {
325 score += 10;
326 } else if (reviews >= 5) {
327 score += 5;
328 issues.push({
329 severity: 'high',
330 field: 'reviewCount',
331 message: `Only ${reviews} reviews. Businesses in the Google Local Pack typically have 20+.`,
332 });
333 recommendations.push(
334 'Set up an automated review request: send a follow-up text or email ' +
335 '24 hours after every transaction with a direct link to your Google review page.'
336 );
337 } else {
338 issues.push({
339 severity: 'critical',
340 field: 'reviewCount',
341 message: `Only ${reviews} reviews. This is a critical gap — new customers rarely trust businesses with fewer than 5 reviews.`,
342 });
343 recommendations.push(
344 'Ask your most loyal existing customers personally to leave a review. ' +
345 'Even 10 reviews dramatically improves credibility.'
346 );
347 }
348
349
350 if (place.reviewsDistribution) {
351 const dist = place.reviewsDistribution;
352 const oneStars = dist.oneStar ?? 0;
353 const total = reviews || 1;
354 if (oneStars / total > 0.15) {
355 issues.push({
356 severity: 'medium',
357 field: 'reviewDistribution',
358 message: `${Math.round((oneStars / total) * 100)}% of reviews are 1-star. This is above the 15% threshold that signals a systemic issue.`,
359 });
360 recommendations.push(
361 'Investigate the common themes in 1-star reviews. ' +
362 'A pattern usually points to a fixable operational problem.'
363 );
364 }
365 }
366
367
368 const images = place.imagesCount ?? 0;
369 if (images >= 20) {
370 score += 15;
371 } else if (images >= 10) {
372 score += 10;
373 } else if (images >= 3) {
374 score += 5;
375 issues.push({
376 severity: 'medium',
377 field: 'images',
378 message: `Only ${images} photos. Listings with 10+ photos get significantly more views.`,
379 });
380 recommendations.push(
381 'Add photos of: exterior (for navigation), interior, staff, and your top products/dishes. ' +
382 'Use real photos — stock images are flagged by Google.'
383 );
384 } else {
385 issues.push({
386 severity: 'high',
387 field: 'images',
388 message: `Only ${images} photos. This is one of the most impactful quick wins available.`,
389 });
390 recommendations.push(
391 'Upload at least 10 photos today. ' +
392 'Photos are the first thing potential customers look at before calling.'
393 );
394 }
395
396
397 if (place.categories && place.categories.length > 0) {
398 score += 5;
399 } else if (!place.categoryName) {
400 issues.push({
401 severity: 'medium',
402 field: 'category',
403 message: 'No business category detected.',
404 });
405 recommendations.push(
406 'Add a primary category and as many relevant secondary categories as apply. ' +
407 'Categories determine which searches your business appears in.'
408 );
409 } else {
410 score += 5;
411 }
412
413
414 if (place.address && place.city) {
415 score += 10;
416 } else if (place.address) {
417 score += 5;
418 issues.push({
419 severity: 'low',
420 field: 'address',
421 message: 'Address may be incomplete — city not detected separately.',
422 });
423 } else {
424 issues.push({
425 severity: 'high',
426 field: 'address',
427 message: 'No address detected. This prevents appearing in "near me" searches.',
428 });
429 recommendations.push(
430 'Verify your address in Google Business Profile → Info. ' +
431 'Ensure it exactly matches your address on your website and other directories.'
432 );
433 }
434
435
436 if (place.permanentlyClosed) {
437 issues.push({
438 severity: 'critical',
439 field: 'status',
440 message: 'Google Maps shows this business as PERMANENTLY CLOSED.',
441 });
442 recommendations.push(
443 'If this is incorrect, log in to Google Business Profile immediately and update the status.'
444 );
445 } else if (place.temporarilyClosed) {
446 issues.push({
447 severity: 'high',
448 field: 'status',
449 message: 'Google Maps shows this business as TEMPORARILY CLOSED.',
450 });
451 recommendations.push(
452 'If you have reopened, update your status in Google Business Profile → Info.'
453 );
454 }
455
456
457 manualChecks.push({
458 field: 'businessDescription',
459 message: 'Manually verify: does your profile have a 400–750 character business description?',
460 why: 'Description is not returned by the Google Maps scraper. Log in to GBP to check.',
461 });
462 manualChecks.push({
463 field: 'recentPosts',
464 message: 'Manually verify: have you posted a Google Business update in the last 7 days?',
465 why: 'Recent posts signal activity to Google and appear directly in your listing.',
466 });
467 manualChecks.push({
468 field: 'servicesOrMenu',
469 message: 'Manually verify: are your services, products, or menu items listed in GBP?',
470 why: 'Listed services help Google match your business to more relevant queries.',
471 });
472 manualChecks.push({
473 field: 'questionsAndAnswers',
474 message: 'Manually verify: have you pre-populated the Q&A section with common questions?',
475 why: 'Pre-answered Q&As appear in your listing and reduce friction for new customers.',
476 });
477
478 return { score, issues, manualChecks, recommendations };
479}
480
481
482
483function summariseCompetitor(place) {
484 return {
485 name: place.title,
486 rating: place.totalScore ?? null,
487 reviewCount: place.reviewsCount ?? 0,
488 hasWebsite: !!place.website,
489 imageCount: place.imagesCount ?? 0,
490 rank: place.rank ?? null,
491 mapsUrl: place.url ?? null,
492 };
493}
494
495
496
497
498function buildCompetitiveInsights(targetProfile, competitorList) {
499 const insights = [];
500
501 if (competitorList.length === 0) return insights;
502
503 const topReviews = Math.max(...competitorList.map((c) => c.reviewsCount ?? 0));
504 const topImages = Math.max(...competitorList.map((c) => c.imagesCount ?? 0));
505 const topRating = Math.max(...competitorList.map((c) => c.totalScore ?? 0));
506
507 const targetReviews = targetProfile.reviewsCount ?? 0;
508 const targetImages = targetProfile.imagesCount ?? 0;
509 const targetRating = targetProfile.totalScore ?? 0;
510
511
512 if (topReviews > targetReviews) {
513 const gap = topReviews - targetReviews;
514 const topCompetitor = competitorList.find((c) => (c.reviewsCount ?? 0) === topReviews);
515 insights.push({
516 type: 'review_gap',
517 message: `Your top competitor (${topCompetitor?.title ?? 'a nearby business'}) has ${topReviews} reviews vs your ${targetReviews} — a gap of ${gap}.`,
518 action: gap > 50
519 ? 'Set up an automated post-visit review request (SMS or email) to close this gap systematically.'
520 : 'Ask your 10 most loyal customers personally for a review this week to start closing this gap.',
521 });
522 } else {
523 insights.push({
524 type: 'review_lead',
525 message: `You lead all nearby competitors in review count (${targetReviews} reviews). Maintain this by continuing to request reviews consistently.`,
526 action: 'Keep your review velocity up — even market leaders lose ground when they stop asking.',
527 });
528 }
529
530
531 if (topImages > targetImages) {
532 const gap = topImages - targetImages;
533 const topCompetitor = competitorList.find((c) => (c.imagesCount ?? 0) === topImages);
534 insights.push({
535 type: 'photo_gap',
536 message: `${topCompetitor?.title ?? 'A competitor'} has ${topImages} photos vs your ${targetImages} — a gap of ${gap}.`,
537 action: 'Upload photos of your exterior, interior, staff, and top products. Aim to exceed the competitor count.',
538 });
539 } else {
540 insights.push({
541 type: 'photo_lead',
542 message: `You have more photos (${targetImages}) than all nearby competitors. This is a strong trust signal — keep adding fresh photos monthly.`,
543 action: 'Add new photos at least once a month to signal an active, maintained listing.',
544 });
545 }
546
547
548 if (topRating > targetRating + 0.2) {
549 const topCompetitor = competitorList.find((c) => (c.totalScore ?? 0) === topRating);
550 insights.push({
551 type: 'rating_gap',
552 message: `${topCompetitor?.title ?? 'A competitor'} has a higher rating (${topRating.toFixed(1)}) than you (${targetRating.toFixed(1)}).`,
553 action: 'Review your 1-star and 2-star feedback for recurring themes — one fixable issue often drives most negative reviews.',
554 });
555 } else if (targetRating > 0) {
556 insights.push({
557 type: 'rating_lead',
558 message: `Your rating (${targetRating.toFixed(1)}) is at or above all nearby competitors. Protect it by responding to every review — positive and negative.`,
559 action: 'Respond to reviews within 24 hours. Response rate is a ranking signal.',
560 });
561 }
562
563
564 const noWebsite = competitorList.filter((c) => !c.website);
565 if (noWebsite.length > 0 && targetProfile.website) {
566 insights.push({
567 type: 'website_advantage',
568 message: `${noWebsite.length} of your ${competitorList.length} nearby competitors have no website. Your linked website is a direct ranking advantage.`,
569 action: 'Make sure your website URL in GBP is current and the site loads fast on mobile.',
570 });
571 }
572
573 return insights;
574}
575
576
577
578const { score, issues, manualChecks, recommendations } = scoreProfile(target);
579
580const MAX_AUTO_SCORE = 90;
581const grade =
582 score >= 75 ? 'A' :
583 score >= 55 ? 'B' :
584 score >= 35 ? 'C' : 'D';
585
586const highPriorityCount = issues.filter(
587 (i) => i.severity === 'critical' || i.severity === 'high'
588).length;
589
590const competitiveInsights = buildCompetitiveInsights(target, competitors);
591
592const output = {
593
594 businessName: target.title ?? businessName,
595 searchedAs: businessName,
596 city: target.city ?? city,
597 state: target.state ?? state,
598 auditDate: new Date().toISOString(),
599 mapsUrl: target.url ?? null,
600 placeId: target.placeId ?? null,
601
602
603 score,
604 maxAutoScore: MAX_AUTO_SCORE,
605 grade,
606 summary: `"${target.title ?? businessName}" scored ${score}/${MAX_AUTO_SCORE} (${grade}). ` +
607 `${highPriorityCount} high-priority issue${highPriorityCount !== 1 ? 's' : ''} found. ` +
608 `${competitiveInsights.length} competitive insight${competitiveInsights.length !== 1 ? 's' : ''} generated. ` +
609 `${manualChecks.length} fields require manual verification.`,
610
611
612 profile: {
613 rating: target.totalScore ?? null,
614 reviewCount: target.reviewsCount ?? 0,
615 reviewsDistribution: target.reviewsDistribution ?? null,
616 hasWebsite: !!target.website,
617 website: target.website ?? null,
618 phone: target.phone ?? null,
619 address: target.address ?? null,
620 imageCount: target.imagesCount ?? 0,
621 categories: target.categories ?? (target.categoryName ? [target.categoryName] : []),
622 permanentlyClosed: target.permanentlyClosed ?? false,
623 temporarilyClosed: target.temporarilyClosed ?? false,
624 },
625
626
627 issues,
628 manualChecks,
629 recommendations,
630
631
632 competitiveInsights,
633
634
635 competitors: competitors.map(summariseCompetitor),
636
637
638 poweredBy: 'GBP Auditor on Apify Store',
639 dataSource: 'compass/crawler-google-places',
640};
641
642await Actor.pushData(output);
643
644log.info(
645 `Audit complete. Score: ${score}/${MAX_AUTO_SCORE} (${grade}). ` +
646 `${highPriorityCount} high-priority issues.`
647);
648
649}
650
651try {
652 await run();
653} catch (err) {
654 log.error(`Unexpected error during audit: ${err.message}`);
655 if (err.stack) log.error(err.stack);
656 try {
657 await Actor.pushData({
658 error: `Audit failed unexpectedly: ${err.message}. Please retry — if this persists, contact the actor maintainer.`,
659 });
660 } catch (pushErr) {
661 log.error(`Could not push error data: ${pushErr.message}`);
662 }
663}
664
665await Actor.exit();