1import { Actor, log } from 'apify';
2import { ApifyClient } from 'apify-client';
3
4await Actor.init();
5
6try {
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
93const competitors = items
94 .filter((p) => p.placeId !== target.placeId)
95 .slice(0, maxCompetitors);
96
97
98
99
100
101
102
103
104
105
106function scoreProfile(place) {
107 let score = 0;
108 const issues = [];
109 const recommendations = [];
110 const manualChecks = [];
111
112
113 if (place.phone) {
114 score += 10;
115 } else {
116 issues.push({
117 severity: 'high',
118 field: 'phone',
119 message: 'No phone number detected on Google Maps listing.',
120 });
121 recommendations.push('Add your phone number in Google Business Profile → Info tab.');
122 }
123
124
125 if (place.website) {
126 score += 15;
127 } else {
128 issues.push({
129 severity: 'high',
130 field: 'website',
131 message: 'No website linked to this listing.',
132 });
133 recommendations.push(
134 'Link your website in Google Business Profile → Info. ' +
135 'A missing website significantly hurts local search ranking.'
136 );
137 }
138
139
140 const rating = place.totalScore ?? 0;
141 if (rating >= 4.5) {
142 score += 15;
143 } else if (rating >= 4.0) {
144 score += 10;
145 } else if (rating >= 3.5) {
146 score += 5;
147 issues.push({
148 severity: 'medium',
149 field: 'rating',
150 message: `Rating is ${rating.toFixed(1)} — below 4.0 reduces click-through meaningfully.`,
151 });
152 recommendations.push(
153 'Systematically ask satisfied customers for reviews to raise your average.'
154 );
155 } else if (rating > 0) {
156 issues.push({
157 severity: 'high',
158 field: 'rating',
159 message: `Rating is ${rating.toFixed(1)} — below 3.5 significantly deters new customers.`,
160 });
161 recommendations.push(
162 'Respond professionally to all negative reviews. ' +
163 'Address the underlying issues and build a stream of new positive reviews.'
164 );
165 } else {
166 issues.push({
167 severity: 'high',
168 field: 'rating',
169 message: 'No rating found — business may have no reviews.',
170 });
171 }
172
173
174 const reviews = place.reviewsCount ?? 0;
175 if (reviews >= 100) {
176 score += 20;
177 } else if (reviews >= 50) {
178 score += 15;
179 } else if (reviews >= 20) {
180 score += 10;
181 } else if (reviews >= 5) {
182 score += 5;
183 issues.push({
184 severity: 'high',
185 field: 'reviewCount',
186 message: `Only ${reviews} reviews. Businesses in the Google Local Pack typically have 20+.`,
187 });
188 recommendations.push(
189 'Set up an automated review request: send a follow-up text or email ' +
190 '24 hours after every transaction with a direct link to your Google review page.'
191 );
192 } else {
193 issues.push({
194 severity: 'critical',
195 field: 'reviewCount',
196 message: `Only ${reviews} reviews. This is a critical gap — new customers rarely trust businesses with fewer than 5 reviews.`,
197 });
198 recommendations.push(
199 'Ask your most loyal existing customers personally to leave a review. ' +
200 'Even 10 reviews dramatically improves credibility.'
201 );
202 }
203
204
205 if (place.reviewsDistribution) {
206 const dist = place.reviewsDistribution;
207 const oneStars = dist.oneStar ?? 0;
208 const total = reviews || 1;
209 if (oneStars / total > 0.15) {
210 issues.push({
211 severity: 'medium',
212 field: 'reviewDistribution',
213 message: `${Math.round((oneStars / total) * 100)}% of reviews are 1-star. This is above the 15% threshold that signals a systemic issue.`,
214 });
215 recommendations.push(
216 'Investigate the common themes in 1-star reviews. ' +
217 'A pattern usually points to a fixable operational problem.'
218 );
219 }
220 }
221
222
223 const images = place.imagesCount ?? 0;
224 if (images >= 20) {
225 score += 15;
226 } else if (images >= 10) {
227 score += 10;
228 } else if (images >= 3) {
229 score += 5;
230 issues.push({
231 severity: 'medium',
232 field: 'images',
233 message: `Only ${images} photos. Listings with 10+ photos get significantly more views.`,
234 });
235 recommendations.push(
236 'Add photos of: exterior (for navigation), interior, staff, and your top products/dishes. ' +
237 'Use real photos — stock images are flagged by Google.'
238 );
239 } else {
240 issues.push({
241 severity: 'high',
242 field: 'images',
243 message: `Only ${images} photos. This is one of the most impactful quick wins available.`,
244 });
245 recommendations.push(
246 'Upload at least 10 photos today. ' +
247 'Photos are the first thing potential customers look at before calling.'
248 );
249 }
250
251
252 if (place.categories && place.categories.length > 0) {
253 score += 5;
254 } else if (!place.categoryName) {
255 issues.push({
256 severity: 'medium',
257 field: 'category',
258 message: 'No business category detected.',
259 });
260 recommendations.push(
261 'Add a primary category and as many relevant secondary categories as apply. ' +
262 'Categories determine which searches your business appears in.'
263 );
264 } else {
265 score += 5;
266 }
267
268
269 if (place.address && place.city) {
270 score += 10;
271 } else if (place.address) {
272 score += 5;
273 issues.push({
274 severity: 'low',
275 field: 'address',
276 message: 'Address may be incomplete — city not detected separately.',
277 });
278 } else {
279 issues.push({
280 severity: 'high',
281 field: 'address',
282 message: 'No address detected. This prevents appearing in "near me" searches.',
283 });
284 recommendations.push(
285 'Verify your address in Google Business Profile → Info. ' +
286 'Ensure it exactly matches your address on your website and other directories.'
287 );
288 }
289
290
291 if (place.permanentlyClosed) {
292 issues.push({
293 severity: 'critical',
294 field: 'status',
295 message: 'Google Maps shows this business as PERMANENTLY CLOSED.',
296 });
297 recommendations.push(
298 'If this is incorrect, log in to Google Business Profile immediately and update the status.'
299 );
300 } else if (place.temporarilyClosed) {
301 issues.push({
302 severity: 'high',
303 field: 'status',
304 message: 'Google Maps shows this business as TEMPORARILY CLOSED.',
305 });
306 recommendations.push(
307 'If you have reopened, update your status in Google Business Profile → Info.'
308 );
309 }
310
311
312 manualChecks.push({
313 field: 'businessDescription',
314 message: 'Manually verify: does your profile have a 400–750 character business description?',
315 why: 'Description is not returned by the Google Maps scraper. Log in to GBP to check.',
316 });
317 manualChecks.push({
318 field: 'recentPosts',
319 message: 'Manually verify: have you posted a Google Business update in the last 7 days?',
320 why: 'Recent posts signal activity to Google and appear directly in your listing.',
321 });
322 manualChecks.push({
323 field: 'servicesOrMenu',
324 message: 'Manually verify: are your services, products, or menu items listed in GBP?',
325 why: 'Listed services help Google match your business to more relevant queries.',
326 });
327 manualChecks.push({
328 field: 'questionsAndAnswers',
329 message: 'Manually verify: have you pre-populated the Q&A section with common questions?',
330 why: 'Pre-answered Q&As appear in your listing and reduce friction for new customers.',
331 });
332
333 return { score, issues, manualChecks, recommendations };
334}
335
336
337
338function summariseCompetitor(place) {
339 return {
340 name: place.title,
341 rating: place.totalScore ?? null,
342 reviewCount: place.reviewsCount ?? 0,
343 hasWebsite: !!place.website,
344 imageCount: place.imagesCount ?? 0,
345 rank: place.rank ?? null,
346 mapsUrl: place.url ?? null,
347 };
348}
349
350
351
352
353function buildCompetitiveInsights(targetProfile, competitorList) {
354 const insights = [];
355
356 if (competitorList.length === 0) return insights;
357
358 const topReviews = Math.max(...competitorList.map((c) => c.reviewsCount ?? 0));
359 const topImages = Math.max(...competitorList.map((c) => c.imagesCount ?? 0));
360 const topRating = Math.max(...competitorList.map((c) => c.totalScore ?? 0));
361
362 const targetReviews = targetProfile.reviewsCount ?? 0;
363 const targetImages = targetProfile.imagesCount ?? 0;
364 const targetRating = targetProfile.totalScore ?? 0;
365
366
367 if (topReviews > targetReviews) {
368 const gap = topReviews - targetReviews;
369 const topCompetitor = competitorList.find((c) => (c.reviewsCount ?? 0) === topReviews);
370 insights.push({
371 type: 'review_gap',
372 message: `Your top competitor (${topCompetitor?.title ?? 'a nearby business'}) has ${topReviews} reviews vs your ${targetReviews} — a gap of ${gap}.`,
373 action: gap > 50
374 ? 'Set up an automated post-visit review request (SMS or email) to close this gap systematically.'
375 : 'Ask your 10 most loyal customers personally for a review this week to start closing this gap.',
376 });
377 } else {
378 insights.push({
379 type: 'review_lead',
380 message: `You lead all nearby competitors in review count (${targetReviews} reviews). Maintain this by continuing to request reviews consistently.`,
381 action: 'Keep your review velocity up — even market leaders lose ground when they stop asking.',
382 });
383 }
384
385
386 if (topImages > targetImages) {
387 const gap = topImages - targetImages;
388 const topCompetitor = competitorList.find((c) => (c.imagesCount ?? 0) === topImages);
389 insights.push({
390 type: 'photo_gap',
391 message: `${topCompetitor?.title ?? 'A competitor'} has ${topImages} photos vs your ${targetImages} — a gap of ${gap}.`,
392 action: 'Upload photos of your exterior, interior, staff, and top products. Aim to exceed the competitor count.',
393 });
394 } else {
395 insights.push({
396 type: 'photo_lead',
397 message: `You have more photos (${targetImages}) than all nearby competitors. This is a strong trust signal — keep adding fresh photos monthly.`,
398 action: 'Add new photos at least once a month to signal an active, maintained listing.',
399 });
400 }
401
402
403 if (topRating > targetRating + 0.2) {
404 const topCompetitor = competitorList.find((c) => (c.totalScore ?? 0) === topRating);
405 insights.push({
406 type: 'rating_gap',
407 message: `${topCompetitor?.title ?? 'A competitor'} has a higher rating (${topRating.toFixed(1)}) than you (${targetRating.toFixed(1)}).`,
408 action: 'Review your 1-star and 2-star feedback for recurring themes — one fixable issue often drives most negative reviews.',
409 });
410 } else if (targetRating > 0) {
411 insights.push({
412 type: 'rating_lead',
413 message: `Your rating (${targetRating.toFixed(1)}) is at or above all nearby competitors. Protect it by responding to every review — positive and negative.`,
414 action: 'Respond to reviews within 24 hours. Response rate is a ranking signal.',
415 });
416 }
417
418
419 const noWebsite = competitorList.filter((c) => !c.website);
420 if (noWebsite.length > 0 && targetProfile.website) {
421 insights.push({
422 type: 'website_advantage',
423 message: `${noWebsite.length} of your ${competitorList.length} nearby competitors have no website. Your linked website is a direct ranking advantage.`,
424 action: 'Make sure your website URL in GBP is current and the site loads fast on mobile.',
425 });
426 }
427
428 return insights;
429}
430
431
432
433const { score, issues, manualChecks, recommendations } = scoreProfile(target);
434
435const MAX_AUTO_SCORE = 90;
436const grade =
437 score >= 75 ? 'A' :
438 score >= 55 ? 'B' :
439 score >= 35 ? 'C' : 'D';
440
441const highPriorityCount = issues.filter(
442 (i) => i.severity === 'critical' || i.severity === 'high'
443).length;
444
445const competitiveInsights = buildCompetitiveInsights(target, competitors);
446
447const output = {
448
449 businessName: target.title ?? businessName,
450 searchedAs: businessName,
451 city: target.city ?? city,
452 state: target.state ?? state,
453 auditDate: new Date().toISOString(),
454 mapsUrl: target.url ?? null,
455 placeId: target.placeId ?? null,
456
457
458 score,
459 maxAutoScore: MAX_AUTO_SCORE,
460 grade,
461 summary: `"${target.title ?? businessName}" scored ${score}/${MAX_AUTO_SCORE} (${grade}). ` +
462 `${highPriorityCount} high-priority issue${highPriorityCount !== 1 ? 's' : ''} found. ` +
463 `${competitiveInsights.length} competitive insight${competitiveInsights.length !== 1 ? 's' : ''} generated. ` +
464 `${manualChecks.length} fields require manual verification.`,
465
466
467 profile: {
468 rating: target.totalScore ?? null,
469 reviewCount: target.reviewsCount ?? 0,
470 reviewsDistribution: target.reviewsDistribution ?? null,
471 hasWebsite: !!target.website,
472 website: target.website ?? null,
473 phone: target.phone ?? null,
474 address: target.address ?? null,
475 imageCount: target.imagesCount ?? 0,
476 categories: target.categories ?? (target.categoryName ? [target.categoryName] : []),
477 permanentlyClosed: target.permanentlyClosed ?? false,
478 temporarilyClosed: target.temporarilyClosed ?? false,
479 },
480
481
482 issues,
483 manualChecks,
484 recommendations,
485
486
487 competitiveInsights,
488
489
490 competitors: competitors.map(summariseCompetitor),
491
492
493 poweredBy: 'GBP Auditor on Apify Store',
494 dataSource: 'compass/crawler-google-places',
495};
496
497await Actor.pushData(output);
498
499log.info(
500 `Audit complete. Score: ${score}/${MAX_AUTO_SCORE} (${grade}). ` +
501 `${highPriorityCount} high-priority issues.`
502);
503
504} catch (err) {
505 log.error(`Unexpected error during audit: ${err.message}`);
506 if (err.stack) log.error(err.stack);
507 try {
508 await Actor.pushData({
509 error: `Audit failed unexpectedly: ${err.message}. Please retry — if this persists, contact the actor maintainer.`,
510 });
511 } catch (pushErr) {
512 log.error(`Could not push error data: ${pushErr.message}`);
513 }
514}
515
516await Actor.exit();