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