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