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