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