first commit

This commit is contained in:
Alaguraj0361 2026-06-18 16:38:39 +05:30
commit 20c9b97e7a
10 changed files with 3294 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
PORT=3000
GOOGLE_OAUTH_FILE=./gmb_audit.json
GOOGLE_REDIRECT_URI=http://localhost:3000/oauth2callback
GOOGLE_MAPS_API_KEY=your_google_maps_platform_api_key_here

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.tokens/
.env

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# Google Business Profile Audit App
Small local Node.js app for connecting a Google account and listing the Google Business Profile accounts and businesses available to that user.
## Setup
1. Install dependencies:
```powershell
npm install
```
2. In Google Cloud Console, make sure these APIs are enabled for the OAuth project:
- My Business Account Management API
- Business Information API
- Places API, if you want competitor lookup
3. Make sure your OAuth client allows this redirect URI:
```text
http://localhost:3000/oauth2callback
```
Your current `gmb_audit.json` is an installed-app OAuth client. If Google rejects the redirect URI, add the URI above to the OAuth client or create a Web application OAuth client and download its JSON.
4. Start the app:
```powershell
npm start
```
5. Open:
```text
http://localhost:3000
```
6. Click an account, then click any business card to run the first audit checklist for that location.
7. For competitor lookup, add a Google Maps Platform API key to `.env`:
```text
GOOGLE_MAPS_API_KEY=your_google_maps_platform_api_key_here
```
Then restart the app and use the Competitor Lookup search box.
## Notes
- Tokens are saved locally in `.tokens/user.json`.
- Keep `gmb_audit.json` and `.tokens/` private. They contain credentials.
- If account or location calls return quota errors, request/confirm Google Business Profile API access for the project.
- The audit checks core Business Profile fields and tries to load reviews, services, media, posts, attributes, and food menus. Some sections are only available for verified or eligible locations, so unavailable sections are shown separately.
- Competitor lookup uses the Places API public `primaryType` and `types` fields. These are not the full private GBP primary/secondary category assignments shown inside the GBP owner dashboard.

1
gmb_audit.json Normal file
View File

@ -0,0 +1 @@
{"installed":{"client_id":"744141178000-d183tfvsjot6vgpq7li4k1krco89ghfl.apps.googleusercontent.com","project_id":"gmb-audit-489406","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}

1124
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "gmb-audit-googleapi",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^5.1.0",
"google-auth-library": "^10.7.0"
}
}

772
public/app.js Normal file
View File

@ -0,0 +1,772 @@
const statusEl = document.querySelector('#status');
const connectButton = document.querySelector('#connectButton');
const disconnectButton = document.querySelector('#disconnectButton');
const refreshAccountsButton = document.querySelector('#refreshAccounts');
const accountsEl = document.querySelector('#accounts');
const locationsEl = document.querySelector('#locations');
const businessCountEl = document.querySelector('#businessCount');
const auditEl = document.querySelector('#audit');
const auditScoreEl = document.querySelector('#auditScore');
const competitorForm = document.querySelector('#competitorForm');
const competitorQueryInput = document.querySelector('#competitorQuery');
const competitorsEl = document.querySelector('#competitors');
let selectedAccount = null;
let selectedLocation = null;
let currentAuditLocation = null;
async function api(path, options) {
const response = await fetch(path, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || `Request failed with ${response.status}`);
}
return data;
}
async function jsonApi(path, method, body) {
return api(path, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
}
function setConnectionState(connected) {
connectButton.hidden = connected;
disconnectButton.hidden = !connected;
statusEl.textContent = connected ? 'Google account connected.' : 'No Google account connected.';
}
function showError(element, error) {
if (element === locationsEl) {
element.className = 'businessGrid error';
} else if (element === auditEl) {
element.className = 'audit error';
} else if (element === competitorsEl) {
element.className = 'competitorGrid error';
} else {
element.className = 'list error';
}
element.textContent = error.message;
}
function accountLabel(account) {
return account.accountName || account.name || 'Unnamed account';
}
function formatAddress(address) {
if (!address) return '';
const lines = address.addressLines || [];
const cityLine = [address.locality, address.administrativeArea, address.postalCode]
.filter(Boolean)
.join(', ');
return [...lines, cityLine].filter(Boolean).join(', ');
}
function humanDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function starRating(value) {
const ratings = {
ONE: '1',
TWO: '2',
THREE: '3',
FOUR: '4',
FIVE: '5'
};
return ratings[value] || value || 'No rating';
}
function renderAccounts(accounts) {
accountsEl.className = 'list';
accountsEl.innerHTML = '';
if (!accounts.length) {
accountsEl.className = 'list empty';
accountsEl.textContent = 'No Business Profile accounts were returned for this Google user.';
return;
}
for (const account of accounts) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'account';
button.setAttribute('aria-selected', selectedAccount === account.name ? 'true' : 'false');
button.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
`;
button.querySelector('.title').textContent = accountLabel(account);
button.querySelector('.meta').textContent = `${account.name} ${account.type ? `- ${account.type}` : ''}`;
button.addEventListener('click', () => loadLocations(account.name));
accountsEl.append(button);
}
}
function renderLocations(locations) {
locationsEl.className = 'businessGrid';
locationsEl.innerHTML = '';
businessCountEl.textContent = `${locations.length} found`;
if (!locations.length) {
locationsEl.className = 'businessGrid empty';
locationsEl.textContent = 'No businesses were returned for this account.';
return;
}
for (const location of locations) {
const article = document.createElement('article');
article.className = 'business';
article.dataset.locationName = location.name;
article.setAttribute('aria-selected', selectedLocation === location.name ? 'true' : 'false');
const address = formatAddress(location.storefrontAddress);
const phone = location.phoneNumbers?.primaryPhone || '';
article.innerHTML = `
<span class="title"></span>
<span class="meta location-id"></span>
<span class="meta store-code"></span>
<span class="meta address"></span>
<span class="meta phone"></span>
<span class="meta website"></span>
<button class="button small auditButton" type="button">Run audit</button>
`;
article.querySelector('.title').textContent = location.title || 'Untitled business';
article.querySelector('.location-id').textContent = location.name;
article.querySelector('.store-code').textContent = location.storeCode ? `Store code: ${location.storeCode}` : '';
article.querySelector('.address').textContent = address;
article.querySelector('.phone').textContent = phone;
article.querySelector('.website').textContent = location.websiteUri || '';
article.querySelector('.auditButton').addEventListener('click', () => loadAudit(location.name));
locationsEl.append(article);
}
}
function renderCompetitors(places) {
competitorsEl.className = 'competitorGrid';
competitorsEl.innerHTML = '';
if (!places.length) {
competitorsEl.className = 'competitorGrid empty';
competitorsEl.textContent = 'No competitors found for that search.';
return;
}
for (const place of places) {
const card = document.createElement('article');
card.className = 'competitor';
card.innerHTML = `
<span class="title"></span>
<span class="meta address"></span>
<div class="typeBlock">
<span class="typeLabel">Primary type</span>
<span class="typeValue primary-type"></span>
</div>
<div class="typeBlock">
<span class="typeLabel">All public types</span>
<span class="typeValue all-types"></span>
</div>
<div class="meta stats"></div>
<div class="competitorLinks"></div>
`;
card.querySelector('.title').textContent = place.name || 'Unnamed place';
card.querySelector('.address').textContent = place.address;
card.querySelector('.primary-type').textContent = place.primaryTypeDisplayName || place.primaryType || 'Not returned';
card.querySelector('.all-types').textContent = place.types.length ? place.types.join(', ') : 'No public types returned';
card.querySelector('.stats').textContent = [
place.rating ? `Rating: ${place.rating}` : '',
place.userRatingCount ? `${place.userRatingCount} reviews` : '',
place.businessStatus ? `Status: ${place.businessStatus}` : ''
].filter(Boolean).join(' - ');
const links = card.querySelector('.competitorLinks');
for (const [label, href] of [
['Maps', place.googleMapsUri],
['Website', place.websiteUri]
]) {
if (!href) continue;
const link = document.createElement('a');
link.href = href;
link.target = '_blank';
link.rel = 'noreferrer';
link.textContent = label;
links.append(link);
}
if (place.nationalPhoneNumber || place.internationalPhoneNumber) {
const phone = document.createElement('span');
phone.className = 'meta';
phone.textContent = place.nationalPhoneNumber || place.internationalPhoneNumber;
links.append(phone);
}
competitorsEl.append(card);
}
}
function sectionError(section) {
const message = document.createElement('p');
message.className = 'error sectionError';
message.textContent = section.error;
return message;
}
function createSection(title, section) {
const wrapper = document.createElement('section');
wrapper.className = 'auditSection';
wrapper.innerHTML = `
<div class="sectionHeader">
<h3></h3>
<span class="muted"></span>
</div>
`;
wrapper.querySelector('h3').textContent = title;
wrapper.querySelector('.muted').textContent = section.ok
? 'Loaded'
: section.skipped
? 'Skipped'
: `Not available (${section.statusCode || 'error'})`;
return wrapper;
}
function renderReviews(section) {
const wrapper = createSection('Reviews', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.count}</strong><small>reviews fetched</small></span>
<span><strong>${data.averageRating ?? 'N/A'}</strong><small>average rating</small></span>
<span><strong>${data.repliedCount}</strong><small>with replies</small></span>
<span><strong>${data.unrepliedCount}</strong><small>without replies</small></span>
`;
const list = document.createElement('div');
list.className = 'detailList';
for (const review of data.latest || []) {
const item = document.createElement('article');
item.className = 'detailItem';
item.dataset.reviewName = review.name;
item.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
<p></p>
<label class="replyBox">
<span class="meta reply"></span>
<textarea rows="3" maxlength="4096"></textarea>
</label>
<div class="reviewActions">
<button class="button small saveReply" type="button">Save reply</button>
<button class="button small deleteReply" type="button">Delete reply</button>
<span class="meta replyStatus" role="status"></span>
</div>
`;
item.querySelector('.title').textContent = `${starRating(review.starRating)} star - ${review.reviewer?.displayName || 'Anonymous'}`;
item.querySelector('.meta').textContent = `Updated ${humanDate(review.updateTime || review.createTime)}`;
item.querySelector('p').textContent = review.comment || 'No written comment.';
item.querySelector('.reply').textContent = review.reviewReply ? `Owner reply, updated ${humanDate(review.reviewReply.updateTime)}` : 'Owner reply';
item.querySelector('textarea').value = review.reviewReply?.comment || '';
item.querySelector('.saveReply').addEventListener('click', () => saveReviewReply(item));
item.querySelector('.deleteReply').disabled = !review.reviewReply;
item.querySelector('.deleteReply').addEventListener('click', () => deleteReviewReply(item));
list.append(item);
}
wrapper.append(stats, list);
return wrapper;
}
function renderServices(section) {
const wrapper = createSection('Services', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.serviceItemCount}</strong><small>service items</small></span>
<span><strong>${data.source || 'serviceList'}</strong><small>source</small></span>
`;
const list = document.createElement('div');
list.className = 'detailList compact';
for (const service of data.serviceItems || []) {
const item = document.createElement('article');
item.className = 'detailItem';
item.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
<p></p>
`;
item.querySelector('.title').textContent = service.name || 'Service';
item.querySelector('.meta').textContent = [
service.category ? `Category: ${service.category}` : '',
service.isOffered === false ? 'Not offered' : 'Offered'
].filter(Boolean).join(' - ');
item.querySelector('p').textContent = service.description || '';
list.append(item);
}
if (!data.serviceItemCount) {
list.className = 'empty';
list.textContent = 'No service items returned for this location.';
}
wrapper.append(stats, list);
return wrapper;
}
function renderMedia(section) {
const wrapper = createSection('Media', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.totalMediaItemCount}</strong><small>total media items</small></span>
<span><strong>${data.count}</strong><small>loaded in preview</small></span>
`;
const grid = document.createElement('div');
grid.className = 'mediaGrid';
for (const media of data.latest || []) {
const link = document.createElement('a');
link.href = media.googleUrl || media.sourceUrl || '#';
link.target = '_blank';
link.rel = 'noreferrer';
link.className = 'mediaItem';
link.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
`;
link.querySelector('.title').textContent = media.mediaFormat || 'Media item';
link.querySelector('.meta').textContent = humanDate(media.createTime) || media.name || '';
grid.append(link);
}
wrapper.append(stats, grid);
return wrapper;
}
function renderPosts(section) {
const wrapper = createSection('Posts', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.count}</strong><small>posts fetched</small></span>
`;
const list = document.createElement('div');
list.className = 'detailList';
for (const post of data.latest || []) {
const item = document.createElement('article');
item.className = 'detailItem';
item.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
<p></p>
`;
item.querySelector('.title').textContent = `${post.topicType || 'Post'} - ${post.state || 'unknown state'}`;
item.querySelector('.meta').textContent = humanDate(post.updateTime || post.createTime);
item.querySelector('p').textContent = post.summary || post.callToAction?.url || 'No summary.';
list.append(item);
}
if (!data.count) {
list.className = 'empty';
list.textContent = 'No local posts returned for this location.';
}
wrapper.append(stats, list);
return wrapper;
}
function renderAttributes(section) {
const wrapper = createSection('Attributes', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.count}</strong><small>attributes returned</small></span>
`;
const list = document.createElement('div');
list.className = 'detailList compact';
for (const attribute of data.attributes || []) {
const item = document.createElement('article');
item.className = 'detailItem';
item.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
`;
item.querySelector('.title').textContent = attribute.attributeId || 'Attribute';
item.querySelector('.meta').textContent = attribute.valueType || JSON.stringify(attribute.values || attribute.repeatedEnumValue || attribute);
list.append(item);
}
if (!data.count) {
list.className = 'empty';
list.textContent = 'No attributes returned for this location.';
}
wrapper.append(stats, list);
return wrapper;
}
function renderFoodMenus(section) {
const wrapper = createSection('Food menus', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.menuCount}</strong><small>menus</small></span>
<span><strong>${data.sectionCount}</strong><small>sections</small></span>
<span><strong>${data.itemCount}</strong><small>items</small></span>
`;
wrapper.append(stats);
return wrapper;
}
function renderGoogleUpdated(section) {
const wrapper = createSection('Google updates', section);
if (!section.ok) {
wrapper.append(sectionError(section));
return wrapper;
}
const data = section.data;
const stats = document.createElement('div');
stats.className = 'statGrid';
stats.innerHTML = `
<span><strong>${data.diffMask || 'None'}</strong><small>changed fields</small></span>
<span><strong>${data.pendingMask || 'None'}</strong><small>pending fields</small></span>
<span><strong>${data.serviceItemCount}</strong><small>updated service items</small></span>
`;
const categoryBlock = document.createElement('div');
categoryBlock.className = 'detailItem';
categoryBlock.innerHTML = `
<span class="title">Google-updated categories</span>
<span class="meta primary-category"></span>
<span class="meta additional-categories"></span>
`;
categoryBlock.querySelector('.primary-category').textContent = data.categories?.primary
? `Primary: ${data.categories.primary}`
: 'Primary: not returned';
categoryBlock.querySelector('.additional-categories').textContent = data.categories?.additional?.length
? `Additional: ${data.categories.additional.join(', ')}`
: 'Additional: none returned';
const list = document.createElement('div');
list.className = 'detailList compact';
for (const service of data.serviceItems || []) {
const item = document.createElement('article');
item.className = 'detailItem';
item.innerHTML = `
<span class="title"></span>
<span class="meta"></span>
`;
item.querySelector('.title').textContent = service.name;
item.querySelector('.meta').textContent = [
service.category ? `Category: ${service.category}` : '',
service.isOffered === false ? 'Not offered' : 'Offered'
].filter(Boolean).join(' - ');
list.append(item);
}
if (!data.serviceItemCount) {
list.className = 'empty';
list.textContent = data.diffMask?.includes('serviceItems')
? 'Google reported service item changes, but did not return item details in this response.'
: 'No Google-updated service items returned.';
}
wrapper.append(stats, categoryBlock, list);
return wrapper;
}
function renderAudit(location, audit, sections = {}) {
auditEl.className = 'audit';
auditEl.innerHTML = '';
auditScoreEl.textContent = `${audit.score}% complete`;
const summary = document.createElement('div');
summary.className = 'auditSummary';
summary.innerHTML = `
<div>
<span class="score"></span>
<span class="muted"></span>
</div>
<div class="auditIdentity">
<span class="title"></span>
<span class="meta location-id"></span>
<span class="meta maps-link"></span>
</div>
`;
summary.querySelector('.score').textContent = `${audit.passedCount}/${audit.totalCount}`;
summary.querySelector('.muted').textContent = 'checks passed';
summary.querySelector('.title').textContent = location.title || 'Untitled business';
summary.querySelector('.location-id').textContent = location.name;
const mapsLink = summary.querySelector('.maps-link');
if (location.metadata?.mapsUri) {
const link = document.createElement('a');
link.href = location.metadata.mapsUri;
link.target = '_blank';
link.rel = 'noreferrer';
link.textContent = 'Open on Google Maps';
mapsLink.append(link);
} else {
mapsLink.textContent = location.metadata?.placeId ? `Place ID: ${location.metadata.placeId}` : '';
}
const checks = document.createElement('div');
checks.className = 'checks';
for (const check of audit.checks) {
const row = document.createElement('div');
row.className = check.passed ? 'check passed' : 'check missing';
row.innerHTML = `
<span class="checkStatus"></span>
<span>
<span class="title"></span>
<span class="meta"></span>
</span>
`;
row.querySelector('.checkStatus').textContent = check.passed ? 'OK' : 'Missing';
row.querySelector('.title').textContent = check.label;
row.querySelector('.meta').textContent = check.detail;
checks.append(row);
}
const details = document.createElement('div');
details.className = 'auditSections';
details.append(
renderReviews(sections.reviews),
renderServices(sections.services),
renderMedia(sections.media),
renderPosts(sections.posts),
renderAttributes(sections.attributes),
renderFoodMenus(sections.foodMenus),
renderGoogleUpdated(sections.googleUpdated)
);
auditEl.append(summary, checks, details);
}
async function loadStatus() {
const { connected } = await api('/api/status');
setConnectionState(connected);
if (connected) {
await loadAccounts();
}
}
async function loadAccounts() {
accountsEl.className = 'list empty';
accountsEl.textContent = 'Loading accounts...';
locationsEl.className = 'businessGrid empty';
locationsEl.textContent = 'Select an account to load businesses.';
businessCountEl.textContent = '';
auditEl.className = 'audit empty';
auditEl.textContent = 'Select a business to run the first audit.';
auditScoreEl.textContent = '';
try {
const { accounts } = await api('/api/accounts');
renderAccounts(accounts);
} catch (error) {
showError(accountsEl, error);
}
}
async function loadLocations(accountName) {
selectedAccount = accountName;
selectedLocation = null;
for (const account of accountsEl.querySelectorAll('.account')) {
account.setAttribute('aria-selected', account.textContent.includes(accountName) ? 'true' : 'false');
}
locationsEl.className = 'businessGrid empty';
locationsEl.textContent = 'Loading businesses...';
businessCountEl.textContent = '';
auditEl.className = 'audit empty';
auditEl.textContent = 'Select a business to run the first audit.';
auditScoreEl.textContent = '';
try {
const { locations } = await api(`/api/locations?account=${encodeURIComponent(accountName)}`);
renderLocations(locations);
} catch (error) {
businessCountEl.textContent = '';
showError(locationsEl, error);
}
}
async function loadAudit(locationName) {
selectedLocation = locationName;
currentAuditLocation = locationName;
for (const business of locationsEl.querySelectorAll('.business')) {
business.setAttribute('aria-selected', business.dataset.locationName === locationName ? 'true' : 'false');
}
auditEl.className = 'audit empty';
auditEl.textContent = 'Running audit...';
auditScoreEl.textContent = '';
try {
const query = new URLSearchParams({
account: selectedAccount,
location: locationName
});
const { location, audit, sections } = await api(`/api/audit?${query.toString()}`);
renderAudit(location, audit, sections);
} catch (error) {
showError(auditEl, error);
}
}
async function saveReviewReply(item) {
const status = item.querySelector('.replyStatus');
const saveButton = item.querySelector('.saveReply');
const deleteButton = item.querySelector('.deleteReply');
const textarea = item.querySelector('textarea');
const comment = textarea.value.trim();
if (!comment) {
status.textContent = 'Reply cannot be empty.';
return;
}
saveButton.disabled = true;
deleteButton.disabled = true;
status.textContent = 'Saving...';
try {
await jsonApi('/api/reviews/reply', 'PUT', {
account: selectedAccount,
location: selectedLocation,
review: item.dataset.reviewName,
comment
});
status.textContent = 'Saved.';
await loadAudit(currentAuditLocation);
} catch (error) {
status.textContent = error.message;
saveButton.disabled = false;
deleteButton.disabled = false;
}
}
async function deleteReviewReply(item) {
const status = item.querySelector('.replyStatus');
const saveButton = item.querySelector('.saveReply');
const deleteButton = item.querySelector('.deleteReply');
saveButton.disabled = true;
deleteButton.disabled = true;
status.textContent = 'Deleting...';
try {
await jsonApi('/api/reviews/reply', 'DELETE', {
account: selectedAccount,
location: selectedLocation,
review: item.dataset.reviewName
});
status.textContent = 'Deleted.';
await loadAudit(currentAuditLocation);
} catch (error) {
status.textContent = error.message;
saveButton.disabled = false;
deleteButton.disabled = false;
}
}
disconnectButton.addEventListener('click', async () => {
await api('/logout', { method: 'POST' });
setConnectionState(false);
selectedAccount = null;
selectedLocation = null;
currentAuditLocation = null;
accountsEl.className = 'list empty';
accountsEl.textContent = 'Connect a Google account to load accounts.';
locationsEl.className = 'businessGrid empty';
locationsEl.textContent = 'Select an account to load businesses.';
businessCountEl.textContent = '';
auditEl.className = 'audit empty';
auditEl.textContent = 'Select a business to run the first audit.';
auditScoreEl.textContent = '';
});
refreshAccountsButton.addEventListener('click', loadAccounts);
competitorForm.addEventListener('submit', async (event) => {
event.preventDefault();
const query = competitorQueryInput.value.trim();
if (!query) return;
competitorsEl.className = 'competitorGrid empty';
competitorsEl.textContent = 'Searching competitors...';
try {
const params = new URLSearchParams({ q: query, limit: '10' });
const { places } = await api(`/api/competitors/search?${params.toString()}`);
renderCompetitors(places);
} catch (error) {
showError(competitorsEl, error);
}
});
loadStatus().catch((error) => {
setConnectionState(false);
showError(accountsEl, error);
});

67
public/index.html Normal file
View File

@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GMB Audit</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>GMB Audit</h1>
<p id="status">Checking connection...</p>
</div>
<div class="actions">
<a class="button primary" href="/auth" id="connectButton">Connect Google</a>
<button class="button" id="disconnectButton" hidden>Disconnect</button>
</div>
</header>
<section class="layout">
<aside class="panel">
<div class="panelHeader">
<h2>Accounts</h2>
<button class="button small" id="refreshAccounts">Refresh</button>
</div>
<div id="accounts" class="list empty">Connect a Google account to load accounts.</div>
</aside>
<div class="contentStack">
<section class="panel">
<div class="panelHeader">
<div>
<h2>Competitor Lookup</h2>
<p class="muted">Uses Places API public place types, not private GBP categories.</p>
</div>
</div>
<form id="competitorForm" class="searchForm">
<input id="competitorQuery" type="search" placeholder="Example: dentist near Toronto, ON" autocomplete="off">
<button class="button primary" type="submit">Search</button>
</form>
<div id="competitors" class="competitorGrid empty">Enter a competitor name, category, or local search query.</div>
</section>
<section class="panel">
<div class="panelHeader">
<h2>Businesses</h2>
<span id="businessCount" class="muted"></span>
</div>
<div id="locations" class="businessGrid empty">Select an account to load businesses.</div>
</section>
<section class="panel">
<div class="panelHeader">
<h2>Audit</h2>
<span id="auditScore" class="muted"></span>
</div>
<div id="audit" class="audit empty">Select a business to run the first audit.</div>
</section>
</div>
</section>
</main>
<script src="/app.js?v=12" type="module"></script>
</body>
</html>

451
public/styles.css Normal file
View File

@ -0,0 +1,451 @@
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--border: #d9dee7;
--text: #1f2937;
--muted: #667085;
--primary: #155eef;
--primary-hover: #004eeb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0;
}
.topbar,
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 15px;
}
#status,
.muted {
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.button {
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
color: var(--text);
cursor: pointer;
font: inherit;
padding: 9px 13px;
text-decoration: none;
}
.button:hover {
border-color: #a9b4c5;
}
.button.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.button.primary:hover {
background: var(--primary-hover);
}
.button.small {
padding: 6px 10px;
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 16px;
margin-top: 16px;
}
.contentStack {
display: grid;
gap: 16px;
min-width: 0;
}
.panel {
min-width: 0;
padding: 16px;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.list,
.businessGrid,
.competitorGrid {
display: grid;
gap: 10px;
}
.empty,
.error {
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.error {
color: #b42318;
}
.account,
.business,
.competitor {
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 12px;
}
.account {
width: 100%;
cursor: pointer;
text-align: left;
}
.account[aria-selected="true"],
.business[aria-selected="true"] {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(21, 94, 239, 0.12);
}
.title {
display: block;
font-weight: 650;
overflow-wrap: anywhere;
}
.meta {
color: var(--muted);
display: block;
font-size: 13px;
margin-top: 4px;
overflow-wrap: anywhere;
}
.businessGrid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.competitorGrid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
margin-top: 12px;
}
.business,
.competitor {
display: grid;
gap: 8px;
align-content: start;
}
.searchForm {
display: grid;
gap: 10px;
grid-template-columns: 1fr auto;
}
.searchForm input {
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font: inherit;
min-width: 0;
padding: 9px 11px;
}
.searchForm input:focus {
border-color: var(--primary);
outline: 2px solid rgba(21, 94, 239, 0.12);
}
.typeBlock {
display: grid;
gap: 3px;
}
.typeLabel {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.typeValue {
font-size: 14px;
overflow-wrap: anywhere;
}
.competitorLinks {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.competitorLinks a {
color: var(--primary);
font-size: 14px;
}
.auditButton {
justify-self: start;
margin-top: 4px;
}
.audit {
display: grid;
gap: 16px;
}
.auditSummary {
align-items: start;
border-bottom: 1px solid var(--border);
display: grid;
gap: 16px;
grid-template-columns: 140px 1fr;
padding-bottom: 16px;
}
.score {
display: block;
font-size: 32px;
font-weight: 750;
line-height: 1;
}
.auditIdentity {
min-width: 0;
}
.checks {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.check {
align-items: start;
border: 1px solid var(--border);
border-radius: 8px;
display: grid;
gap: 10px;
grid-template-columns: 72px 1fr;
padding: 12px;
}
.checkStatus {
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 700;
justify-content: center;
padding: 4px 8px;
}
.check.passed .checkStatus {
background: #dcfae6;
color: #067647;
}
.check.missing .checkStatus {
background: #fee4e2;
color: #b42318;
}
.maps-link a {
color: var(--primary);
}
.auditSections {
display: grid;
gap: 16px;
}
.auditSection {
border-top: 1px solid var(--border);
display: grid;
gap: 12px;
padding-top: 16px;
}
.sectionHeader {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
}
.sectionError {
margin: 0;
}
.statGrid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.statGrid span,
.detailItem,
.mediaItem {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
}
.statGrid strong {
display: block;
font-size: 20px;
}
.statGrid small {
color: var(--muted);
}
.detailList {
display: grid;
gap: 10px;
}
.detailList.compact {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.detailItem {
display: grid;
gap: 6px;
}
.detailItem p {
font-size: 14px;
line-height: 1.45;
}
.replyBox {
display: grid;
gap: 6px;
}
.replyBox textarea {
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font: inherit;
min-height: 86px;
padding: 9px;
resize: vertical;
width: 100%;
}
.replyBox textarea:focus {
border-color: var(--primary);
outline: 2px solid rgba(21, 94, 239, 0.12);
}
.reviewActions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.mediaGrid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.mediaItem {
color: inherit;
display: grid;
gap: 6px;
text-decoration: none;
}
@media (max-width: 800px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
}
.actions {
justify-content: flex-start;
}
.searchForm {
grid-template-columns: 1fr;
}
.auditSummary {
grid-template-columns: 1fr;
}
}

802
src/server.js Normal file
View File

@ -0,0 +1,802 @@
import express from 'express';
import 'dotenv/config';
import { OAuth2Client } from 'google-auth-library';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const PORT = Number(process.env.PORT || 3000);
const OAUTH_FILE = path.resolve(rootDir, process.env.GOOGLE_OAUTH_FILE || 'gmb_audit.json');
const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/oauth2callback`;
const TOKEN_DIR = path.resolve(rootDir, '.tokens');
const TOKEN_FILE = path.join(TOKEN_DIR, 'user.json');
const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || '';
const SCOPES = ['https://www.googleapis.com/auth/business.manage'];
const ACCOUNT_MANAGEMENT_BASE = 'https://mybusinessaccountmanagement.googleapis.com/v1';
const BUSINESS_INFORMATION_BASE = 'https://mybusinessbusinessinformation.googleapis.com/v1';
const GMB_V4_BASE = 'https://mybusiness.googleapis.com/v4';
const PLACES_BASE = 'https://places.googleapis.com/v1';
const PLACES_FIELD_MASK = [
'places.id',
'places.displayName',
'places.formattedAddress',
'places.primaryType',
'places.primaryTypeDisplayName',
'places.types',
'places.rating',
'places.userRatingCount',
'places.websiteUri',
'places.nationalPhoneNumber',
'places.internationalPhoneNumber',
'places.googleMapsUri',
'places.businessStatus',
'places.location'
].join(',');
const LOCATION_READ_MASK = [
'name',
'title',
'storeCode',
'storefrontAddress',
'phoneNumbers',
'websiteUri',
'regularHours',
'metadata',
'profile'
].join(',');
const AUDIT_READ_MASK = [
'name',
'title',
'storeCode',
'languageCode',
'storefrontAddress',
'phoneNumbers',
'websiteUri',
'regularHours',
'specialHours',
'moreHours',
'categories',
'serviceArea',
'labels',
'adWordsLocationExtensions',
'latlng',
'openInfo',
'metadata',
'profile'
].join(',');
const GOOGLE_UPDATED_READ_MASK = [
'name',
'title',
'categories',
'serviceItems',
'metadata',
'phoneNumbers',
'websiteUri',
'profile'
].join(',');
const app = express();
app.use(express.json({ limit: '32kb' }));
app.use(express.static(path.join(rootDir, 'public')));
let oauthClientPromise;
async function loadOAuthClient() {
if (!oauthClientPromise) {
oauthClientPromise = (async () => {
const rawCredentials = await fs.readFile(OAUTH_FILE, 'utf8');
const credentials = JSON.parse(rawCredentials);
const clientConfig = credentials.web || credentials.installed;
if (!clientConfig) {
throw new Error('OAuth JSON must contain either a "web" or "installed" client.');
}
const client = new OAuth2Client(
clientConfig.client_id,
clientConfig.client_secret,
REDIRECT_URI
);
client.on('tokens', async (newTokens) => {
const currentTokens = (await readStoredTokens()) || {};
await writeStoredTokens({ ...currentTokens, ...newTokens });
});
return client;
})();
}
return oauthClientPromise;
}
async function readStoredTokens() {
try {
return JSON.parse(await fs.readFile(TOKEN_FILE, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async function writeStoredTokens(tokens) {
await fs.mkdir(TOKEN_DIR, { recursive: true });
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2));
}
async function authorizedClient() {
const client = await loadOAuthClient();
const tokens = await readStoredTokens();
if (!tokens) {
const error = new Error('Google account is not connected.');
error.statusCode = 401;
throw error;
}
client.setCredentials(tokens);
return client;
}
async function googleApiGet(url) {
const client = await authorizedClient();
const response = await client.request({ url, method: 'GET' });
return response.data;
}
async function googleApiRequest({ url, method, data }) {
const client = await authorizedClient();
const response = await client.request({ url, method, data });
return response.data;
}
async function placesApiSearch(textQuery, maxResultCount = 10) {
if (!GOOGLE_MAPS_API_KEY) {
const error = new Error('Missing GOOGLE_MAPS_API_KEY. Add a Google Maps Platform API key to .env and enable Places API.');
error.statusCode = 400;
throw error;
}
const response = await fetch(`${PLACES_BASE}/places:searchText`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': GOOGLE_MAPS_API_KEY,
'X-Goog-FieldMask': PLACES_FIELD_MASK
},
body: JSON.stringify({
textQuery,
maxResultCount
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error?.message || `Places API request failed with ${response.status}.`);
error.statusCode = response.status;
error.response = { status: response.status, data };
throw error;
}
return data;
}
function normalizePlace(place) {
return {
id: place.id,
name: place.displayName?.text || '',
address: place.formattedAddress || '',
primaryType: place.primaryType || '',
primaryTypeDisplayName: place.primaryTypeDisplayName?.text || '',
types: place.types || [],
rating: place.rating ?? null,
userRatingCount: place.userRatingCount ?? null,
websiteUri: place.websiteUri || '',
nationalPhoneNumber: place.nationalPhoneNumber || '',
internationalPhoneNumber: place.internationalPhoneNumber || '',
googleMapsUri: place.googleMapsUri || '',
businessStatus: place.businessStatus || '',
location: place.location || null
};
}
async function listAllPages(url, collectionKey) {
const items = [];
let pageToken;
do {
const pageUrl = new URL(url);
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
const data = await googleApiGet(pageUrl.toString());
items.push(...(data[collectionKey] || []));
pageToken = data.nextPageToken;
} while (pageToken);
return items;
}
async function readOptionalSection(label, loader) {
try {
const data = await loader();
return { label, ok: true, data };
} catch (error) {
const statusCode = error.response?.status || error.statusCode || 500;
const apiError = typeof error.response?.data === 'object' ? error.response.data?.error : null;
const rawMessage = apiError?.message || error.message || `Could not load ${label}.`;
const fallbackMessages = {
services: 'Services are not available through the Google Business Profile API for this location or project.',
foodMenus: 'Food menus are only available for eligible restaurant/food business categories.'
};
const htmlError = typeof rawMessage === 'string' && /<\/?[a-z][\s\S]*>/i.test(rawMessage);
const internalGoogleError = typeof rawMessage === 'string'
&& rawMessage.includes('com.google.')
&& rawMessage.includes('Exception');
return {
label,
ok: false,
error: htmlError || internalGoogleError ? fallbackMessages[label] || `Could not load ${label}.` : rawMessage,
statusCode
};
}
}
function skippedSection(label, message) {
return {
label,
ok: false,
skipped: true,
error: message,
statusCode: 'skipped'
};
}
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function hasArrayItems(value) {
return Array.isArray(value) && value.length > 0;
}
function hasAddress(location) {
const address = location.storefrontAddress;
return Boolean(address && hasArrayItems(address.addressLines) && hasText(address.locality));
}
function hasRegularHours(location) {
return hasArrayItems(location.regularHours?.periods);
}
function categoryName(category) {
return category?.displayName || category?.name || '';
}
function categoryId(category) {
return category?.categoryId || category?.name || '';
}
function isFoodCategory(location) {
const categories = [
location.categories?.primaryCategory,
...(location.categories?.additionalCategories || [])
].filter(Boolean);
return categories.some((category) => {
const text = [
categoryName(category),
categoryId(category)
].join(' ').toLowerCase();
return [
'restaurant',
'food',
'cafe',
'coffee',
'bakery',
'bar',
'pub',
'pizza',
'grill',
'diner',
'bistro',
'cater',
'takeout',
'meal',
'sandwich',
'ice cream',
'dessert'
].some((keyword) => text.includes(keyword));
});
}
function buildAudit(location) {
const checks = [
{
key: 'businessName',
label: 'Business name',
passed: hasText(location.title),
detail: location.title || 'Missing business name'
},
{
key: 'address',
label: 'Address',
passed: hasAddress(location),
detail: hasAddress(location) ? 'Address is present' : 'Missing or incomplete storefront address'
},
{
key: 'primaryPhone',
label: 'Primary phone',
passed: hasText(location.phoneNumbers?.primaryPhone),
detail: location.phoneNumbers?.primaryPhone || 'Missing primary phone'
},
{
key: 'website',
label: 'Website',
passed: hasText(location.websiteUri),
detail: location.websiteUri || 'Missing website URL'
},
{
key: 'regularHours',
label: 'Regular hours',
passed: hasRegularHours(location),
detail: hasRegularHours(location) ? `${location.regularHours.periods.length} hour period(s) set` : 'Missing regular hours'
},
{
key: 'primaryCategory',
label: 'Primary category',
passed: Boolean(location.categories?.primaryCategory),
detail: categoryName(location.categories?.primaryCategory) || 'Missing primary category'
},
{
key: 'additionalCategories',
label: 'Additional categories',
passed: hasArrayItems(location.categories?.additionalCategories),
detail: hasArrayItems(location.categories?.additionalCategories)
? `${location.categories.additionalCategories.length} additional categor${location.categories.additionalCategories.length === 1 ? 'y' : 'ies'}`
: 'No additional categories set'
},
{
key: 'description',
label: 'Business description',
passed: hasText(location.profile?.description),
detail: location.profile?.description || 'Missing business description'
},
{
key: 'storeCode',
label: 'Store code',
passed: hasText(location.storeCode),
detail: location.storeCode || 'Missing internal store code'
},
{
key: 'mapsMetadata',
label: 'Maps metadata',
passed: hasText(location.metadata?.mapsUri) || hasText(location.metadata?.placeId),
detail: location.metadata?.mapsUri || location.metadata?.placeId || 'Missing Maps URL/place ID metadata'
}
];
const passedCount = checks.filter((check) => check.passed).length;
const score = Math.round((passedCount / checks.length) * 100);
return {
score,
passedCount,
totalCount: checks.length,
checks,
categories: {
primary: categoryName(location.categories?.primaryCategory),
additional: (location.categories?.additionalCategories || []).map(categoryName).filter(Boolean)
}
};
}
function buildReviewSummary(reviews) {
const ratingValues = {
ONE: 1,
TWO: 2,
THREE: 3,
FOUR: 4,
FIVE: 5
};
const numericRatings = reviews
.map((review) => ratingValues[review.starRating])
.filter((rating) => typeof rating === 'number');
const repliedCount = reviews.filter((review) => Boolean(review.reviewReply)).length;
const averageRating = numericRatings.length
? Number((numericRatings.reduce((sum, rating) => sum + rating, 0) / numericRatings.length).toFixed(2))
: null;
return {
count: reviews.length,
averageRating,
repliedCount,
unrepliedCount: reviews.length - repliedCount,
latest: reviews.slice(0, 10)
};
}
function buildServiceSummary(serviceList) {
const serviceItems = serviceList?.serviceItems || [];
return {
serviceList: serviceList || null,
name: serviceList?.name || '',
source: serviceList?.source || 'serviceList',
serviceItemCount: serviceItems.length,
serviceItems: serviceItems.map((serviceItem) => ({
name: serviceItemName(serviceItem),
description: serviceItem?.freeFormServiceItem?.label?.description
|| serviceItem?.structuredServiceItem?.description
|| '',
category: serviceItem?.freeFormServiceItem?.category
|| serviceItem?.freeFormServiceItem?.categoryId
|| '',
isOffered: serviceItem?.isOffered,
price: serviceItem?.price || null,
raw: serviceItem
}))
};
}
function buildMediaSummary(mediaItems, totalMediaItemCount) {
return {
count: mediaItems.length,
totalMediaItemCount: totalMediaItemCount ?? mediaItems.length,
latest: mediaItems.slice(0, 12)
};
}
function buildPostSummary(localPosts) {
return {
count: localPosts.length,
latest: localPosts.slice(0, 10)
};
}
function buildAttributeSummary(attributesResponse) {
const attributes = attributesResponse?.attributes || [];
return {
count: attributes.length,
attributes
};
}
function buildFoodMenuSummary(foodMenus) {
const menus = foodMenus?.menus || [];
const sectionCount = menus.reduce((count, menu) => count + (menu.sections?.length || 0), 0);
const itemCount = menus.reduce(
(count, menu) => count + (menu.sections || []).reduce((sum, section) => sum + (section.items?.length || 0), 0),
0
);
return {
foodMenus: foodMenus || null,
menuCount: menus.length,
sectionCount,
itemCount
};
}
function serviceItemName(serviceItem) {
return serviceItem?.freeFormServiceItem?.label?.displayName
|| serviceItem?.structuredServiceItem?.serviceTypeId
|| serviceItem?.displayName
|| serviceItem?.name
|| 'Service item';
}
async function loadServiceSummary(serviceListUrl, locationName) {
try {
return buildServiceSummary(await googleApiGet(serviceListUrl));
} catch (error) {
const statusCode = error.response?.status || error.statusCode;
if (statusCode !== 404) throw error;
const locationUrl = new URL(`${BUSINESS_INFORMATION_BASE}/${locationName}`);
locationUrl.searchParams.set('readMask', 'serviceItems');
const locationWithServices = await googleApiGet(locationUrl.toString());
return buildServiceSummary({
source: 'businessInformation.serviceItems',
serviceItems: locationWithServices.serviceItems || []
});
}
}
function buildGoogleUpdatedSummary(googleUpdated) {
const location = googleUpdated?.location || {};
const serviceItems = location.serviceItems || [];
return {
diffMask: googleUpdated?.diffMask || '',
pendingMask: googleUpdated?.pendingMask || '',
categories: {
primary: categoryName(location.categories?.primaryCategory),
additional: (location.categories?.additionalCategories || []).map(categoryName).filter(Boolean)
},
serviceItemCount: serviceItems.length,
serviceItems: serviceItems.map((serviceItem) => ({
name: serviceItemName(serviceItem),
category: serviceItem?.freeFormServiceItem?.categoryId || '',
isOffered: serviceItem?.isOffered,
price: serviceItem?.price || null,
raw: serviceItem
}))
};
}
app.get('/auth', async (_req, res, next) => {
try {
const client = await loadOAuthClient();
const url = client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: SCOPES
});
res.redirect(url);
} catch (error) {
next(error);
}
});
app.get('/oauth2callback', async (req, res, next) => {
try {
if (!req.query.code || typeof req.query.code !== 'string') {
res.status(400).send('Missing OAuth code.');
return;
}
const client = await loadOAuthClient();
const { tokens } = await client.getToken(req.query.code);
await writeStoredTokens(tokens);
res.redirect('/');
} catch (error) {
next(error);
}
});
app.post('/logout', async (_req, res, next) => {
try {
const tokens = await readStoredTokens();
if (tokens) {
const client = await loadOAuthClient();
client.setCredentials(tokens);
await client.revokeCredentials().catch(() => {});
await fs.rm(TOKEN_FILE, { force: true });
}
res.json({ connected: false });
} catch (error) {
next(error);
}
});
app.get('/api/status', async (_req, res, next) => {
try {
res.json({ connected: Boolean(await readStoredTokens()) });
} catch (error) {
next(error);
}
});
app.get('/api/accounts', async (_req, res, next) => {
try {
const url = new URL(`${ACCOUNT_MANAGEMENT_BASE}/accounts`);
url.searchParams.set('pageSize', '20');
const accounts = await listAllPages(url.toString(), 'accounts');
res.json({ accounts });
} catch (error) {
next(error);
}
});
app.get('/api/locations', async (req, res, next) => {
try {
const accountName = req.query.account;
if (!accountName || typeof accountName !== 'string' || !accountName.startsWith('accounts/')) {
res.status(400).json({ error: 'Query parameter "account" must look like accounts/123456789.' });
return;
}
const url = new URL(`${BUSINESS_INFORMATION_BASE}/${accountName}/locations`);
url.searchParams.set('pageSize', '100');
url.searchParams.set('readMask', LOCATION_READ_MASK);
const locations = await listAllPages(url.toString(), 'locations');
res.json({ locations });
} catch (error) {
next(error);
}
});
app.get('/api/competitors/search', async (req, res, next) => {
try {
const query = req.query.q;
const limit = Math.min(Math.max(Number(req.query.limit || 10), 1), 20);
if (!query || typeof query !== 'string' || !query.trim()) {
res.status(400).json({ error: 'Query parameter "q" is required.' });
return;
}
const data = await placesApiSearch(query.trim(), limit);
res.json({
query: query.trim(),
places: (data.places || []).map(normalizePlace)
});
} catch (error) {
next(error);
}
});
app.get('/api/audit', async (req, res, next) => {
try {
const accountName = req.query.account;
const locationName = req.query.location;
if (!accountName || typeof accountName !== 'string' || !accountName.startsWith('accounts/')) {
res.status(400).json({ error: 'Query parameter "account" must look like accounts/123456789.' });
return;
}
if (!locationName || typeof locationName !== 'string' || !locationName.startsWith('locations/')) {
res.status(400).json({ error: 'Query parameter "location" must look like locations/123456789.' });
return;
}
const url = new URL(`${BUSINESS_INFORMATION_BASE}/${locationName}`);
url.searchParams.set('readMask', AUDIT_READ_MASK);
const location = await googleApiGet(url.toString());
const v4LocationName = `${accountName}/${locationName}`;
const reviewsUrl = new URL(`${GMB_V4_BASE}/${v4LocationName}/reviews`);
reviewsUrl.searchParams.set('pageSize', '50');
reviewsUrl.searchParams.set('orderBy', 'updateTime desc');
const mediaUrl = new URL(`${GMB_V4_BASE}/${v4LocationName}/media`);
mediaUrl.searchParams.set('pageSize', '100');
const postsUrl = new URL(`${GMB_V4_BASE}/${v4LocationName}/localPosts`);
postsUrl.searchParams.set('pageSize', '100');
const serviceListUrl = `${GMB_V4_BASE}/${v4LocationName}/serviceList`;
const attributesUrl = `${BUSINESS_INFORMATION_BASE}/${locationName}/attributes`;
const foodMenusUrl = `${GMB_V4_BASE}/${v4LocationName}/foodMenus`;
const googleUpdatedUrl = new URL(`${BUSINESS_INFORMATION_BASE}/${locationName}:getGoogleUpdated`);
googleUpdatedUrl.searchParams.set('readMask', GOOGLE_UPDATED_READ_MASK);
const foodMenusPromise = isFoodCategory(location)
? readOptionalSection('foodMenus', async () => buildFoodMenuSummary(await googleApiGet(foodMenusUrl)))
: Promise.resolve(skippedSection(
'foodMenus',
'Food menus were skipped because this location does not appear to be in a restaurant or food category.'
));
const [reviews, services, media, posts, attributes, foodMenus, googleUpdated] = await Promise.all([
readOptionalSection('reviews', async () => {
const reviewItems = await listAllPages(reviewsUrl.toString(), 'reviews');
return buildReviewSummary(reviewItems);
}),
readOptionalSection('services', async () => loadServiceSummary(serviceListUrl, locationName)),
readOptionalSection('media', async () => {
const response = await googleApiGet(mediaUrl.toString());
return buildMediaSummary(response.mediaItems || [], response.totalMediaItemCount);
}),
readOptionalSection('posts', async () => {
const postItems = await listAllPages(postsUrl.toString(), 'localPosts');
return buildPostSummary(postItems);
}),
readOptionalSection('attributes', async () => buildAttributeSummary(await googleApiGet(attributesUrl))),
foodMenusPromise,
readOptionalSection('googleUpdated', async () => buildGoogleUpdatedSummary(await googleApiGet(googleUpdatedUrl.toString())))
]);
res.json({
location,
audit: buildAudit(location),
sections: {
reviews,
services,
media,
posts,
attributes,
foodMenus,
googleUpdated
}
});
} catch (error) {
next(error);
}
});
app.put('/api/reviews/reply', async (req, res, next) => {
try {
const { account, location, review, comment } = req.body || {};
if (!account || typeof account !== 'string' || !account.startsWith('accounts/')) {
res.status(400).json({ error: 'Body field "account" must look like accounts/123456789.' });
return;
}
if (!location || typeof location !== 'string' || !location.startsWith('locations/')) {
res.status(400).json({ error: 'Body field "location" must look like locations/123456789.' });
return;
}
if (!review || typeof review !== 'string') {
res.status(400).json({ error: 'Body field "review" is required.' });
return;
}
if (!comment || typeof comment !== 'string' || !comment.trim()) {
res.status(400).json({ error: 'Reply comment cannot be empty.' });
return;
}
const reviewId = review.includes('/') ? review.split('/').pop() : review;
const url = `${GMB_V4_BASE}/${account}/${location}/reviews/${encodeURIComponent(reviewId)}/reply`;
const reply = await googleApiRequest({
url,
method: 'PUT',
data: { comment: comment.trim() }
});
res.json({ reply });
} catch (error) {
next(error);
}
});
app.delete('/api/reviews/reply', async (req, res, next) => {
try {
const { account, location, review } = req.body || {};
if (!account || typeof account !== 'string' || !account.startsWith('accounts/')) {
res.status(400).json({ error: 'Body field "account" must look like accounts/123456789.' });
return;
}
if (!location || typeof location !== 'string' || !location.startsWith('locations/')) {
res.status(400).json({ error: 'Body field "location" must look like locations/123456789.' });
return;
}
if (!review || typeof review !== 'string') {
res.status(400).json({ error: 'Body field "review" is required.' });
return;
}
const reviewId = review.includes('/') ? review.split('/').pop() : review;
const url = `${GMB_V4_BASE}/${account}/${location}/reviews/${encodeURIComponent(reviewId)}/reply`;
await googleApiRequest({ url, method: 'DELETE' });
res.json({ deleted: true });
} catch (error) {
next(error);
}
});
app.use((error, _req, res, _next) => {
const statusCode = error.statusCode || error.response?.status || 500;
const apiError = error.response?.data?.error;
const message = apiError?.message || error.message || 'Unexpected server error.';
res.status(statusCode).json({
error: message,
details: apiError || undefined
});
});
app.listen(PORT, () => {
console.log(`GMB audit app running at http://localhost:${PORT}`);
console.log(`OAuth redirect URI: ${REDIRECT_URI}`);
});