Google Maps Reviews Manager — Collect Every Review, Export Cards, Reply with AI, Embed Verbatim Reviews | Proscris
Collect every Google review. Export it, reply to it, embed it — unfiltered.
Version 5 splits the tool into two pages that share one live dataset. REVIEWS is what a business uses: cards, grid, PNG export and a verbatim website embed. GOOGLE MAPS manages the Business Profile behind your pin and answers every review with five ready-made AI drafts. DEVELOPER is the engine: six independent sources, deep-sweep coverage, business intelligence and one master workbook.
Why did earlier versions never reach 100% of the review count?
Google's total includes rating-only reviews (no text), which were dropped. v5 keeps them (has_text=false), dedupes across providers on Google's review_id, polls long Outscraper/Apify jobs instead of timing out, stops the AI planner from lowering your target, and runs a SerpApi deep sweep across sort orders until the coverage goal. Google also counts removed and spam-filtered ratings, so a small gap can remain.
What does the auto-saved file contain?
One .xlsx per run: Summary (metrics, coverage, collected-vs-Google distribution, per-source counts, sweep passes, AI synthesis) · Business (every enriched field + provider) · Master (deduped, 33 columns) · one sheet per source · Topics · Log. CSV/JSON per tab stay available manually in Developer.
Can a business use the embed to hide bad reviews?
No. Every review in the dataset renders, the full 1–5★ distribution and Google total are always shown, there is no minimum-rating option, and the widget re-hashes the reviews array (SHA-256) in the visitor's browser to show a "Dataset unmodified" badge. Only visitors can narrow by star, and the counts stay visible.
Does the embed track visitors?
No. No cookies, storage, analytics or third-party calls. ~20 KB, dependency-free; the only request is your own reviews.json (or none, inline). Reviewer photos are off by default. Semantic HTML in the light DOM plus optional schema.org JSON-LD.
Why use SerpApi, Outscraper, Apify AND Gemini?
Each exposes different fields. Merged with field-level provenance you get the deepest possible picture of a location — and every review that two or more providers returned is flagged as cross-verified.
Every source resolves the business independently and fetches in parallel. Rating-only reviews are kept, records are deduped on Google's review ID, and a SerpApi deep sweep fills coverage gaps.
Run a fetch to generate analysis.
The only source that can write. Scraping is permanently read-only — posting, editing or deleting a reply requires an authenticated call as the verified owner.
Needs an approved Cloud project — new projects sit at zero quota until Google grants Business Profile API access (verified profile, 60+ days old, submitted as owner not manager). Add this page's origin to the OAuth client's authorised JavaScript origins.
Fetch a business above, paste from Google Maps, load the demo, or run a fetch in the Developer page — it appears here live.
—
—
1 / 1
Placed as-is with no background — never cropped into a circle, so it always shows in full.
Uses the listing's logo / profile photo from the enrichment data (Outscraper, SerpApi, Apify) at full resolution. Square or circular photos get the white badge; any other shape is placed as-is with no background.
Avatar
Stars
Date
Quote mark
Footer
Address under the business name
Google badge
Rating number
Edits only affect your cards. Exports keep the edit flag and the Embed always uses the original verbatim text.
Unanswered
Has photos
Include rating-only reviews
Publish your raw Google reviews on your website. Verbatim records only, newest first, full 1–5★ distribution always visible, no option to hide low ratings, SHA-256 integrity badge, zero tracking, semantic HTML + JSON-LD for SEO.
Reviewer photos (off = initials, no Google requests)
Google Maps — Manage Your Business Profile & Reply to Every Review with AI | Google Maps Reviews Manager | Proscris
No reviews loaded
Fetch a business above, load the demo, or run a fetch in Developer. Every unanswered review then gets five AI-drafted replies here — connect Google Business Profile to post them in one click.
Google Business Profile
Drafting and copying replies works without signing in. Connecting adds one-click posting, official review records and listing edits.
Needs a Google Cloud project with the Business Profile APIs enabled and access approved, and this page's origin listed as an authorised JavaScript origin. Same client ID as Developer → My Business.
${r.has_text===false||!r.text?'Rating-only review — no written text.':esc(r.text)}
${r.reply?`
YOUR REPLY${r.reply_date?' · '+esc(String(r.reply_date).slice(0,10)):''} ${esc(r.reply)}
`:''}
${open?(vars.length?`
${vars.map((v,i)=>`
${i+1} · ${esc(v.tone)}${esc(v.text)}
`).join('')}
`
:`
${r._drafting?' drafting five replies…':A.gemini.ready()?'No drafts yet — click '+FA('wand-magic-sparkles')+' Draft, or turn on Auto-draft above.':'Add a Gemini key in the Key Vault '+FA('key')+' to draft replies automatically.'}
`;
$('#more').classList.toggle('hide',show.length>=all.length);
$('#more').textContent=`Show ${Math.min(G.pageN,all.length-show.length)} more (${fmtN(all.length-show.length)} left)`;
$('#qCount').textContent=`${fmtN(all.length)} in view`;
if(V.focus){const f=q.querySelector(`[data-id="${CSS.escape(V.focus)}"]`);if(f){f.classList.add('sel');f.scrollIntoView({block:'center'})}V.focus=null}
}
/* delegated events for the queue */
function wireQueue(){
const q=$('#queue');
q.addEventListener('click',async e=>{
const art=e.target.closest('.qitem');if(!art)return;const r=byId(art.dataset.id);if(!r)return;
const vc=e.target.closest('.qvar');
if(vc){r._varSel=+vc.dataset.v;r._draft=r._vars[r._varSel].text;persist();
art.querySelectorAll('.qvar').forEach((x,i)=>{x.classList.toggle('sel',i===r._varSel);x.setAttribute('aria-pressed',i===r._varSel)});
art.querySelector('.qdraft').value=r._draft;return}
const b=e.target.closest('[data-a]');if(!b)return;const a=b.dataset.a;
if(a==='draft')return draftOne(r,b);
if(a==='copy'||a==='copyreply'){const t=(a==='copy'?art.querySelector('.qdraft')?.value:r.reply)||'';if(!t.trim())return toast('Nothing to copy — pick or write a reply first','er');
try{await navigator.clipboard.writeText(t.trim());toast('Copied — opening the review on Google','ok');window.open(REVIEW_LINK(r),'_blank','noopener')}catch(x){toast('Clipboard blocked','er')}return}
if(a==='post')return postReply(r,art.querySelector('.qdraft')?.value||'',b);
if(a==='edit'){r._editing=true;r._draft=r.reply;render();return}
if(a==='cancel'){r._editing=false;r._draft=null;render();return}
if(a==='del')return delReply(r);
});
q.addEventListener('input',e=>{if(!e.target.classList.contains('qdraft'))return;const r=byId(e.target.closest('.qitem').dataset.id);if(r){r._draft=e.target.value;persist()}});
q.addEventListener('keydown',e=>{const vc=e.target.closest?.('.qvar');if(vc&&(e.key==='Enter'||e.key===' ')){e.preventDefault();vc.click()}});
}
/*═══════════ AI DRAFTING — five distinct replies per review ═══════════*/
async function draft(r,tone){
const Lc=L();if(!A.gemini.ready())throw new Error('Needs a Gemini key (Key Vault)');
tone=tone||G.tone;const voice=Lc.synth?.voice||'';
const ctx=Lc.master.filter(x=>x.has_text&&x!==r).slice(0,30).map(x=>`[${x.rating}★] ${x.text.slice(0,120)}`).join('\n');
const b=Lc.biz;
const t=await gt(`Write FIVE genuinely different owner replies to one Google review.
BUSINESS: ${b.name}${b.category?' ('+b.category+')':''}
${b.address?'LOCATION: '+b.address:''}
OVERALL: ${b.rating??'?'}★ from ${b.review_count?fmtN(b.review_count):'?'} reviews
${voice?'HOUSE VOICE: '+voice:''}
THE REVIEW TO ANSWER
rating: ${r.rating}/5
reviewer: ${r.author}
date: ${r.date_label||'recent'}
text: "${r.has_text===false||!r.text?'(no written text — a star rating only)':r.text}"
WIDER CORPUS (for tone and recurring themes — do not quote it):
${ctx}
REQUIREMENTS
- Five replies, each a genuinely distinct register, not five rewordings of one.
- Suggested spread: (1) warm and personal, (2) professional and concise, (3) specific and detailed — reference exactly what they mentioned,
(4) ${r.rating<=3?'accountable and solution-focused — name the problem plainly, say what changes, invite them back':'enthusiastic and community-minded'}, (5) short and human — under 30 words.
- Use the reviewer's first name at most once. Never open with "Dear".
- 25–80 words each. No corporate boilerplate, no "we value your feedback", no emoji.
- ${r.rating<=3?'Never make excuses and never offer compensation or discounts.':'Do not oversell or add marketing copy.'}
- ${tone==='warm'?'Skew all five warmer and more informal.':tone==='formal'?'Skew all five more formal and restrained.':tone==='short'?'Keep all five under 45 words.':'Vary length naturally across the five.'}
Reply ONLY with JSON:
[{"tone":"2-4 word label","text":"the reply"}]`,{temp:.85,max:2600});
const j=jp(t);if(!Array.isArray(j)||!j.length)throw new Error('Could not parse variations');
const vars=j.slice(0,5).map(x=>({tone:String(x.tone||'Variation').slice(0,28),text:String(x.text||'').trim()})).filter(x=>x.text.length>8);
if(!vars.length)throw new Error('Empty variations');
r._vars=vars;r._varSel=0;r._draft=vars[0].text;r._draftAt=new Date().toISOString();r._draftTone=tone;
return vars;
}
async function draftOne(r,btn){
if(r._drafting)return;
r._drafting=true;if(btn){btn.disabled=true;btn.innerHTML=' Drafting…'}
try{await draft(r);await persist();toast('5 replies drafted for '+r.author,'ok')}
catch(e){toast(e.message,'er')}
finally{r._drafting=false;render()}
}
/* bulk: sequential-ish (2 in flight) so a big backlog doesn't trip Gemini rate limits */
async function draftMany(rows,label){
if(V.busy||!rows.length)return;
V.busy=true;V.stop=false;V.done=0;V.todo=rows.length;bulkUi(label);
const queue=[...rows];let inflight=0,failures=0;
await new Promise(res=>{
const next=()=>{
if(V.stop||(!queue.length&&!inflight))return res();
while(inflight<2&&queue.length&&!V.stop){
const r=queue.shift();inflight++;r._drafting=true;
draft(r).catch(e=>{failures++;if(/rate limit|429/i.test(e.message)){queue.unshift(r);V.stop=failures>6}}).finally(async()=>{r._drafting=false;inflight--;V.done++;bulkUi(label);
if(V.done%3===0)await persist();render();setTimeout(next,failures?1500:250)})}
};next()});
await persist();V.busy=false;bulkUi();render();
toast(V.stop?`Stopped — ${V.done} drafted`:`${V.done} reviews drafted${failures?` · ${failures} failed`:''}`,failures&&!V.done?'er':'ok');
}
function bulkUi(label){
const b=$('#draftAll'),p=$('#draftProg');if(!b)return;
if(V.busy){b.innerHTML=FA('xmark')+' Stop';b.classList.add('dg');p.textContent=`${label||'Drafting'} ${V.done}/${V.todo}`}
else{b.innerHTML=FA('wand-magic-sparkles')+' Draft all unanswered';b.classList.remove('dg');p.textContent=''}
}
function draftAll(){
if(V.busy){V.stop=true;return}
if(!A.gemini.ready())return toast('Needs a Gemini key (Key Vault)','er');
const rows=list().filter(r=>!r.reply&&!r._vars?.length);
if(!rows.length)return toast('Everything in view already has drafts','ok');
if(rows.length>60&&!confirm(`Draft five replies for ${rows.length} reviews? That is ${rows.length} Gemini calls.`))return;
draftMany(rows,'Drafting');
}
/* auto-draft: newest unanswered first, capped, once per dataset — so the page is populated before anyone signs in */
function autoDraft(){
const Lc=L();if(!G.auto||!Lc||!A.gemini.ready()||V.busy||S.running)return;
const key=Lc.id+':'+Lc.master.length;if(V.autoKey===key)return;V.autoKey=key;
const rows=Lc.master.filter(r=>/google/i.test(r.platform||'Google')&&!r.reply&&!r.hidden&&!r._vars?.length)
.sort((a,b)=>String(b.date_iso||'').localeCompare(String(a.date_iso||''))).slice(0,clamp(+G.autoN||25,1,200));
if(rows.length)draftMany(rows,'Auto-drafting');
}
/*═══════════ POST / EDIT / DELETE via Business Profile ═══════════*/
async function postReply(r,txt,btn){
txt=String(txt||'').trim();
if(!S.gbpToken||!r.gbpName)return toast('Connect Google Business Profile and load official reviews first','er');
if(!txt)return toast('Pick a draft or write a reply first','er');
if(btn){btn.disabled=true;btn.innerHTML=' Posting…'}
try{
await A.gbp.reply(r.gbpName,txt);
r.reply=txt;r.reply_date=new Date().toISOString();r._draft=null;r._editing=false;
const g=L().src.gbp?.rows?.find(x=>x.gbpName===r.gbpName);if(g){g.reply=txt;g.reply_date=r.reply_date}
await persist();toast('Reply posted to Google','ok');render();
}catch(e){toast('Failed: '+e.message,'er');render()}
}
async function delReply(r){
if(!r.gbpName||!S.gbpToken||!r.reply)return;
if(!confirm('Delete your public reply to this review?'))return;
try{await A.gbp.delReply(r.gbpName);r.reply=null;r.reply_date=null;
const g=L().src.gbp?.rows?.find(x=>x.gbpName===r.gbpName);if(g){g.reply=null;g.reply_date=null}
await persist();render();toast('Reply deleted','ok')}catch(e){toast('Failed: '+e.message,'er')}
}
/*═══════════ PROFILE (Business Profile) ═══════════*/
const DAYS=['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY','SUNDAY'];
const hhmm=t=>t?`${String(t.hours??0).padStart(2,'0')}:${String(t.minutes??0).padStart(2,'0')}`:'';
function hoursText(rh){
if(!rh?.periods?.length)return'';
const m={};rh.periods.forEach(p=>{(m[p.openDay]=m[p.openDay]||[]).push(`${hhmm(p.openTime)}–${hhmm(p.closeTime)}`)});
return DAYS.map(d=>`${d.slice(0,3)} ${m[d]?m[d].join(', '):'Closed'}`).join('\n');
}
const gbpAddr=l=>{const a=l?.storefrontAddress;return a?[...(a.addressLines||[]),a.locality,a.administrativeArea,a.postalCode].filter(Boolean).join(', '):''};
function renderProfile(){
const con=!!S.gbpToken;
$('#gbpOff').classList.toggle('hide',con);$('#gbpOn').classList.toggle('hide',!con);
if($('#gbpClient')&&!$('#gbpClient').value)$('#gbpClient').value=S.v.gbpClient||'';
if(!con)return;
const sel=$('#gbpLocSel');
sel.innerHTML=S.gbpLocs.map(l=>``).join('')||'';
if(S.gbpLoc)sel.value=S.gbpLoc.name;
if(!V.pDirty)fillForm(S.gbpLoc);
const Lc=L(),loaded=!!Lc?.src?.gbp?.rows?.length;
$('#gbpLoadRev').innerHTML=loaded?`${FA('rotate')} Reload official reviews (${Lc.src.gbp.rows.length})`:FA('download')+' Load official reviews → enables posting';
$('#gbpLoadRev').classList.toggle('pri',!loaded);
}
function fillForm(l){
if(!l)return;
$('#pTitle').textContent=l.title||'—';
$('#pAddr').textContent=gbpAddr(l)||'—';
$('#pCat').textContent=[l.categories?.primaryCategory?.displayName,...(l.categories?.additionalCategories||[]).map(c=>c.displayName)].filter(Boolean).join(' · ')||'—';
$('#pStatus').textContent=(l.openInfo?.status||'—').replace(/_/g,' ').toLowerCase();
if($('#pPhoneRO'))$('#pPhoneRO').textContent=[l.phoneNumbers?.primaryPhone,...(l.phoneNumbers?.additionalPhones||[])].filter(Boolean).join(' · ')||'—';
if($('#pWebRO'))$('#pWebRO').textContent=l.websiteUri||'—';
if($('#pLabels'))$('#pLabels').textContent=(l.labels||[]).join(', ')||'—';
if($('#pResName'))$('#pResName').textContent=l.name||'—';
$('#pDesc').value=l.profile?.description||'';
$('#pPhone').value=l.phoneNumbers?.primaryPhone||'';
$('#pWeb').value=l.websiteUri||'';
$('#pHours').value=hoursText(l.regularHours);
$('#pMaps').href=l.metadata?.mapsUri||L()?.biz?.maps_url||'https://maps.google.com';
$('#pManage').href=l.metadata?.newReviewUri?l.metadata.newReviewUri.replace(/\/review$/,''):'https://business.google.com/';
$('#pDescLen').textContent=`${$('#pDesc').value.length} / 750`;
}
async function saveProfile(){
const l=S.gbpLoc;if(!l||!S.gbpToken)return;
const body={},mask=[];
const desc=$('#pDesc').value.trim(),phone=$('#pPhone').value.trim(),web=$('#pWeb').value.trim();
if(desc!==(l.profile?.description||'')){body.profile={description:desc};mask.push('profile.description')}
if(phone!==(l.phoneNumbers?.primaryPhone||'')){body.phoneNumbers={...(l.phoneNumbers||{}),primaryPhone:phone};mask.push('phoneNumbers')}
if(web!==(l.websiteUri||'')){body.websiteUri=web;mask.push('websiteUri')}
if(!mask.length)return toast('Nothing changed','ok');
if(!confirm(`Update ${mask.join(', ')} on your live Google listing?`))return;
const b=$('#pSave');b.disabled=true;b.innerHTML=' Saving…';
try{
const upd=await A.gbp.patchLocation(l.name,body,mask.join(','));
Object.assign(l,upd||body);V.pDirty=false;
toast('Listing updated — Google may take a few minutes to publish','ok');render();
}catch(e){toast('Google rejected the update: '+e.message,'er')}
finally{b.disabled=false;b.innerHTML=FA('floppy-disk')+' Save to Google'}
}
async function refreshLoc(){
if(!S.gbpLoc)return;
try{const l=await A.gbp.location(S.gbpLoc.name);Object.assign(S.gbpLoc,l);V.pDirty=false;render();toast('Profile refreshed','ok')}
catch(e){toast('Refresh failed: '+e.message,'er')}
}
/* what the public sees on Google Maps — from the enriched dataset, so you can compare with the profile */
/* Flatten any provider shape (array of strings, array of {label:[...]} groups as SerpApi/Apify send, or a
plain object of booleans as Places sends) into readable "a · b · c" text so nothing captured is hidden. */
function flatList(v){
if(v==null||v==='')return'';
if(Array.isArray(v))return v.map(x=>typeof x==='object'&&x?flatList(x):String(x)).filter(Boolean).join(' · ');
if(typeof v==='object')return Object.entries(v).map(([k,x])=>{
const kk=k.replace(/_/g,' ');
if(x===true)return kk;if(x===false||x==null)return'';
if(Array.isArray(x)||typeof x==='object')return kk+': '+flatList(x);
return kk+': '+x}).filter(Boolean).join(' · ');
return String(v);
}
function renderPublic(){
const b=L()?.biz||{},el=$('#pubProf');if(!el)return;
const g=S.gbpLoc;
const diff=(k,gv)=>g&&gv!=null&&b[k]!=null&&String(gv).replace(/\D/g,'')!==String(b[k]).replace(/\D/g,'')?' ≠ profile':'';
const hrs=Array.isArray(b.hours)?b.hours.join('\n'):b.hours&&typeof b.hours==='object'?Object.entries(b.hours).map(([d,v])=>`${d} ${Array.isArray(v)?v.join(', '):v}`).join('\n'):b.hours||'';
const src=k=>b._prov?.[k]?` ${esc(SRC[b._prov[k]]?.l||b._prov[k])}`:'';
const txt=(k,v)=>v?esc(v)+src(k):'—';
const tags=Array.isArray(b.review_tags)?b.review_tags.map(t=>typeof t==='object'?`${t.title||t.keyword||''}${t.count??t.mentions?' ('+(t.count??t.mentions)+')':''}`:String(t)).join(' · '):flatList(b.review_tags);
const rows=[['Name',txt('name',b.name)],['Rating',b.rating?`${b.rating} ★ · ${fmtN(b.review_count||0)} reviews`+src('rating'):'—'],
['Address',txt('address',b.address)],['Plus code',txt('plus_code',b.plus_code)],
['Coordinates',b.lat!=null&&b.lng!=null?`${b.lat}, ${b.lng}`+src('lat'):'—'],
['Phone',esc(b.phone||'—')+(b.phone?src('phone'):'')+diff('phone',g?.phoneNumbers?.primaryPhone)],
['Website',b.website?`${esc(b.website)}`+src('website'):'—'],
['Category',txt('category',[b.category,...(Array.isArray(b.types)?b.types:[])].filter(Boolean).join(' · '))],
['Status',txt('business_status',String(b.business_status||b.open_state||'').replace(/_/g,' ').toLowerCase())+(b.open_now!=null?(b.open_now?' · open now':' · closed now'):'')],
['Price',txt('price',b.price||b.price_level)],['Claimed',b.verified==null?'—':(b.verified?'Yes — verified listing':'No / unknown')+src('verified')],
['Hours',hrs?`