Cap. 20 · Código
Google Meu Negócio: sync + coleta de avaliações
Execução
Rodar em 3 comandos
Requer credencial: apenas gmn_sync.py (sync da ficha) e a coleta ao vivo em reviews_collector.py precisam de OAuth do Google Business Profile (GBP_OAUTH_CLIENT_ID, GBP_OAUTH_CLIENT_SECRET, GBP_OAUTH_REFRESH_TOKEN, GBP_ACCOUNT_ID). Todo o resto — normalização, storage, aggregateRating, JSON-LD e o demo — roda sem nenhum segredo.
$ python3 --version # confirme Python 3$ python3 demo.py # pipeline completo com dados fictícios$ python3 reviews_collector.py --demo # mesma coleta via CLI (--pretty opcional)Saída esperada (python3 demo.py)
============================================================ COLETA DE AVALIAÇÕES — dados fictícios (sem credenciais) Review collection — fictional data (no credentials)============================================================[demo] 6 avaliações fictícias armazenadas / fictional reviews stored Avaliações (reviews) ---------------------------------------------------- ★★★★★ Marina S. Atendimento impecável e entrega no prazo. Recomendo demais! ★★★★☆ João P. Ótima experiência, só achei o estacionamento apertado. ★★★★★ Ana Beatriz Equipe muito atenciosa, resolveram tudo rapidinho. ★★★☆☆ Carlos M. Bom, mas a fila estava grande no horário de pico. ★★★★★ Fernanda L. Melhor da região, sem dúvida. Voltarei sempre. ★★★★☆ Roberto A. (sem comentário / no comment) Nota agregada (aggregate rating) ---------------------------------------------------- ratingValue = 4.3 reviewCount = 6 (best 5 / worst 1) AggregateRating / Review JSON-LD ----------------------------------------------------{ "@context": "https://schema.org", "@type": "LocalBusiness", "name": "Padaria do Bairro", "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.3, "reviewCount": 6, "bestRating": 5, "worstRating": 1 }, "review": [ { "@type": "Review", "author": { "@type": "Person", "name": "Marina S." }, "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-28T14:03:00Z", "reviewBody": "Atendimento impecável e entrega no prazo. Recomendo demais!" }, { "@type": "Review", "author": { "@type": "Person", "name": "João P." }, "reviewRating": { "@type": "Rating", "ratingValue": 4, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-25T09:41:00Z", "reviewBody": "Ótima experiência, só achei o estacionamento apertado." }, { "@type": "Review", "author": { "@type": "Person", "name": "Ana Beatriz" }, "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-20T18:22:00Z", "reviewBody": "Equipe muito atenciosa, resolveram tudo rapidinho." }, { "@type": "Review", "author": { "@type": "Person", "name": "Carlos M." }, "reviewRating": { "@type": "Rating", "ratingValue": 3, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-15T12:10:00Z", "reviewBody": "Bom, mas a fila estava grande no horário de pico." }, { "@type": "Review", "author": { "@type": "Person", "name": "Fernanda L." }, "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-10T20:05:00Z", "reviewBody": "Melhor da região, sem dúvida. Voltarei sempre." }, { "@type": "Review", "author": { "@type": "Person", "name": "Roberto A." }, "reviewRating": { "@type": "Rating", "ratingValue": 4, "bestRating": 5, "worstRating": 1 }, "datePublished": "2026-06-05T08:30:00Z" } ]}============================================================Código-fonte
Código do módulo
demo.py é o caminho sem credencial. gmn_sync.py e as partes de coleta ao vivo de reviews_collector.py precisam de OAuth do Google Business Profile (ver o aviso de credencial acima).
#!/usr/bin/env python3"""Cap. 20 - Standalone offline demo of the GBP review pipeline (ZERO credentials).Runs the whole review-collection pipeline with FICTIONAL data: 1. Creates an in-memory SQLite DB via ``reviews_collector``. 2. Seeds ~6 fictional reviews (no API, no network, no credentials). 3. Prints a per-review star breakdown and the computed ``aggregateRating``. 4. Prints the schema.org AggregateRating / Review JSON-LD.Run it standalone:: python3 demo.pyNothing here touches Google Business Profile - the reviews are made up so youcan see the aggregate rating and JSON-LD working before wiring real OAuthcredentials into ``gmn_sync.py`` / ``reviews_collector.fetch_reviews``.Part of the "living code" companion repository for the book"Aparecer ou Sumir" / "Be Seen or Be Forgotten"."""from __future__ import annotationsimport jsonimport reviews_collector as rcBUSINESS_NAME = "Padaria do Bairro"def main() -> None: conn = rc.connect(":memory:") rc.init_db(conn) written = rc.collect(conn, rc.SAMPLE_REVIEWS) print("=" * 60) print(" COLETA DE AVALIAÇÕES — dados fictícios (sem credenciais)") print(" Review collection — fictional data (no credentials)") print("=" * 60) print(f"\n[demo] {written} avaliações fictícias armazenadas / fictional reviews stored") # Per-review breakdown (stars + author + short text). print("\n Avaliações (reviews)") print(" " + "-" * 52) for r in rc.all_reviews(conn): stars = "★" * int(r["star_rating"]) + "☆" * (5 - int(r["star_rating"])) author = r["author"] or "Anônimo" text = r["text"] or "(sem comentário / no comment)" print(f" {stars} {author:<14} {text}") # Aggregate rating. agg = rc.aggregate_rating(conn) print("\n Nota agregada (aggregate rating)") print(" " + "-" * 52) print( f" ratingValue = {agg['ratingValue']} " f"reviewCount = {agg['reviewCount']} " f"(best {agg['bestRating']} / worst {agg['worstRating']})" ) # JSON-LD. jsonld = rc.build_jsonld(conn, business_name=BUSINESS_NAME) print("\n AggregateRating / Review JSON-LD") print(" " + "-" * 52) print(json.dumps(jsonld, ensure_ascii=False, indent=2)) print("=" * 60) conn.close()if __name__ == "__main__": main()#!/usr/bin/env python3"""Cap. 20 - Automatic review collection for Google Business Profile.Fetches reviews for a business location, normalizes them into a common shape,stores them in SQLite (stdlib ``sqlite3``), computes an ``aggregateRating`` andemits schema.org ``Review`` + ``AggregateRating`` JSON-LD you can drop into apage to show star ratings in search results.Two ways to run: * ``--demo`` : loads FICTIONAL sample reviews (inline), stores them in an in-memory DB, prints the aggregate rating and the JSON-LD. No credentials, no network. * (live) : ``fetch_reviews()`` hits the GBP API. That path is a credential-gated stub — see the REQUER CREDENCIAL block.Usage:: python3 reviews_collector.py --demo python3 reviews_collector.py --demo --db reviews.db --pretty============================================================================ REQUER CREDENCIAL / REQUIRES CREDENTIALS --------------------------------------------------------------------------- Only ``fetch_reviews()`` (the live GBP call) needs OAuth credentials, read from ``os.environ`` via ``gmn_sync``. Everything else - normalization, storage, aggregate rating and JSON-LD - runs with zero credentials, which is exactly what ``--demo`` exercises.============================================================================Part of the "living code" companion repository for the book"Aparecer ou Sumir" / "Be Seen or Be Forgotten"."""from __future__ import annotationsimport argparseimport jsonimport sqlite3from typing import Dict, List, Optional# ---------------------------------------------------------------------------# Fictional sample reviews used by --demo (and by demo.py). Zero credentials.# star_rating is 1..5; text is optional. Dates are ISO-8601.# ---------------------------------------------------------------------------SAMPLE_REVIEWS: List[Dict[str, object]] = [ { "review_id": "demo-1", "author": "Marina S.", "star_rating": 5, "text": "Atendimento impecável e entrega no prazo. Recomendo demais!", "create_time": "2026-06-28T14:03:00Z", }, { "review_id": "demo-2", "author": "João P.", "star_rating": 4, "text": "Ótima experiência, só achei o estacionamento apertado.", "create_time": "2026-06-25T09:41:00Z", }, { "review_id": "demo-3", "author": "Ana Beatriz", "star_rating": 5, "text": "Equipe muito atenciosa, resolveram tudo rapidinho.", "create_time": "2026-06-20T18:22:00Z", }, { "review_id": "demo-4", "author": "Carlos M.", "star_rating": 3, "text": "Bom, mas a fila estava grande no horário de pico.", "create_time": "2026-06-15T12:10:00Z", }, { "review_id": "demo-5", "author": "Fernanda L.", "star_rating": 5, "text": "Melhor da região, sem dúvida. Voltarei sempre.", "create_time": "2026-06-10T20:05:00Z", }, { "review_id": "demo-6", "author": "Roberto A.", "star_rating": 4, "text": None, # a review with a star rating but no written comment "create_time": "2026-06-05T08:30:00Z", },]# Map the GBP API star-rating enum to an integer 1..5._STAR_ENUM = {"ONE": 1, "TWO": 2, "THREE": 3, "FOUR": 4, "FIVE": 5}# ---------------------------------------------------------------------------# Storage (SQLite, stdlib) — fully functional, no credentials.# ---------------------------------------------------------------------------def connect(db_path: str = ":memory:") -> sqlite3.Connection: """Open a SQLite connection with row access by column name.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conndef init_db(conn: sqlite3.Connection) -> None: """Create the reviews table if missing. UNIQUE(review_id) makes ingestion idempotent: re-collecting the same review updates it in place instead of duplicating rows. """ conn.execute( """ CREATE TABLE IF NOT EXISTS reviews ( review_id TEXT PRIMARY KEY, author TEXT, star_rating INTEGER NOT NULL, text TEXT, create_time TEXT ) """ ) conn.commit()def store_reviews(conn: sqlite3.Connection, reviews: List[Dict[str, object]]) -> int: """Upsert normalized reviews keyed by review_id. Returns rows processed.""" prepared = [ ( r["review_id"], r.get("author"), int(r["star_rating"]), r.get("text"), r.get("create_time"), ) for r in reviews ] conn.executemany( """ INSERT INTO reviews (review_id, author, star_rating, text, create_time) VALUES (?, ?, ?, ?, ?) ON CONFLICT(review_id) DO UPDATE SET author = excluded.author, star_rating = excluded.star_rating, text = excluded.text, create_time = excluded.create_time """, prepared, ) conn.commit() return len(prepared)def all_reviews(conn: sqlite3.Connection) -> List[sqlite3.Row]: """Return every stored review, newest first.""" cur = conn.execute( "SELECT review_id, author, star_rating, text, create_time " "FROM reviews ORDER BY create_time DESC" ) return cur.fetchall()# ---------------------------------------------------------------------------# Normalization + aggregate rating + JSON-LD — no credentials.# ---------------------------------------------------------------------------def normalize_review(raw: Dict[str, object]) -> Dict[str, object]: """Normalize a raw GBP review payload into our common shape. Accepts both the GBP API shape (``starRating`` enum, ``reviewId``, ``comment``, ``reviewer.displayName``) and our already-normalized shape, so the demo rows pass through untouched. """ if "star_rating" in raw: # already normalized (e.g. SAMPLE_REVIEWS) return { "review_id": str(raw.get("review_id", "")), "author": raw.get("author"), "star_rating": int(raw["star_rating"]), "text": raw.get("text"), "create_time": raw.get("create_time"), } # GBP API shape. reviewer = raw.get("reviewer") or {} star = raw.get("starRating", "") return { "review_id": str(raw.get("reviewId", "")), "author": reviewer.get("displayName") if isinstance(reviewer, dict) else None, "star_rating": _STAR_ENUM.get(str(star), 0), "text": raw.get("comment"), "create_time": raw.get("createTime"), }def aggregate_rating(conn: sqlite3.Connection) -> Dict[str, object]: """Compute the aggregate rating from all stored reviews. Returns a dict with ``ratingValue`` (mean, 1 decimal), ``reviewCount`` and the ``bestRating`` / ``worstRating`` bounds. """ cur = conn.execute("SELECT COUNT(*) AS n, AVG(star_rating) AS avg FROM reviews") row = cur.fetchone() count = int(row["n"] or 0) mean = round(float(row["avg"]), 1) if count else 0.0 return { "ratingValue": mean, "reviewCount": count, "bestRating": 5, "worstRating": 1, }def build_jsonld( conn: sqlite3.Connection, business_name: str = "Seu Negócio",) -> Dict[str, object]: """Build a schema.org LocalBusiness graph with AggregateRating + Reviews.""" agg = aggregate_rating(conn) reviews_ld = [] for r in all_reviews(conn): review_ld: Dict[str, object] = { "@type": "Review", "author": {"@type": "Person", "name": r["author"] or "Anônimo"}, "reviewRating": { "@type": "Rating", "ratingValue": r["star_rating"], "bestRating": 5, "worstRating": 1, }, } if r["create_time"]: review_ld["datePublished"] = r["create_time"] if r["text"]: review_ld["reviewBody"] = r["text"] reviews_ld.append(review_ld) return { "@context": "https://schema.org", "@type": "LocalBusiness", "name": business_name, "aggregateRating": { "@type": "AggregateRating", "ratingValue": agg["ratingValue"], "reviewCount": agg["reviewCount"], "bestRating": agg["bestRating"], "worstRating": agg["worstRating"], }, "review": reviews_ld, }# ---------------------------------------------------------------------------# Live GBP fetch — REQUER CREDENCIAL / REQUIRES CREDENTIALS.# ---------------------------------------------------------------------------def fetch_reviews(location_id: str, service: Optional[object] = None) -> List[Dict[str, object]]: """Fetch reviews for ``location_id`` from the GBP API (live call). TODO: credential — with an authorized ``service`` from ``gmn_sync.authenticate()``:: resp = service.accounts().locations().reviews().list( parent=f"{GBP_ACCOUNT_ID}/{location_id}", ).execute() return [normalize_review(r) for r in resp.get("reviews", [])] Reviews come paginated; follow ``nextPageToken`` until exhausted. Without credentials this raises ``NotImplementedError`` — use ``--demo`` to run the rest of the pipeline offline. """ import gmn_sync # local import so --demo never requires credentials if service is None and not gmn_sync._have_oauth(): raise NotImplementedError( "fetch_reviews() needs an authenticated GBP session. " "Set the GBP_OAUTH_* env vars, or run with --demo." ) # TODO: credential — replace with the real reviews().list() call. raise NotImplementedError("GBP reviews.list not implemented yet.")# ---------------------------------------------------------------------------# Pipeline + CLI# ---------------------------------------------------------------------------def collect( conn: sqlite3.Connection, reviews: List[Dict[str, object]],) -> int: """Normalize + store a batch of reviews. Returns rows written.""" normalized = [normalize_review(r) for r in reviews] return store_reviews(conn, normalized)def print_summary(conn: sqlite3.Connection, jsonld: Dict[str, object], pretty: bool) -> None: """Print a small text summary of the aggregate rating + the JSON-LD.""" agg = aggregate_rating(conn) stars = "★" * int(round(agg["ratingValue"])) print( f"Aggregate rating: {agg['ratingValue']} {stars} " f"({agg['reviewCount']} reviews)" ) print("\nReview / AggregateRating JSON-LD:") print(json.dumps(jsonld, ensure_ascii=False, indent=2 if pretty else None))def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(description="GBP review collector.") parser.add_argument( "--demo", action="store_true", help="Load fictional sample reviews (no credentials, no network).", ) parser.add_argument( "--location-id", default="locations/DEMO", help="GBP location resource name (used only for the live path).", ) parser.add_argument( "--db", default=":memory:", help="SQLite path (default: in-memory).", ) parser.add_argument( "--business-name", default="Padaria do Bairro", help="Business name to embed in the JSON-LD.", ) parser.add_argument( "--pretty", action="store_true", help="Pretty-print the JSON-LD.", ) args = parser.parse_args(argv) conn = connect(args.db) init_db(conn) if args.demo: written = collect(conn, SAMPLE_REVIEWS) print(f"[demo] {written} avaliações fictícias armazenadas / fictional reviews stored\n") else: reviews = fetch_reviews(args.location_id) # credential-gated written = collect(conn, reviews) print(f"[live] {written} reviews stored from {args.location_id}\n") jsonld = build_jsonld(conn, business_name=args.business_name) print_summary(conn, jsonld, pretty=args.pretty) conn.close() return 0if __name__ == "__main__": raise SystemExit(main())#!/usr/bin/env python3"""Cap. 20 - Google Meu Negócio / Google Business Profile sync - STRUCTURE.Pushes and keeps your business location(s) in sync with the Google BusinessProfile (GBP, a.k.a. "Google Meu Negócio") API: name, categories, openinghours, phone and website. The flow is: authenticate() -> list_locations() -> update_location(id, patch)============================================================================ REQUER CREDENCIAL / REQUIRES CREDENTIALS --------------------------------------------------------------------------- The GBP API only returns/accepts live data through an authenticated OAuth session tied to the Google account that manages the business. Every value below is read ONLY from ``os.environ`` - no secret is ever hardcoded. Until the credentials are set, ``authenticate()`` / ``list_locations()`` / ``update_location()`` raise ``NotImplementedError`` with setup guidance, but the module stays importable and ``py_compile``-clean. There is NO offline sample path here on purpose: writing to GBP is a real, side-effecting operation. To see the companion pipeline run offline with fictional data, use ``reviews_collector.py --demo`` or ``demo.py``.============================================================================Part of the "living code" companion repository for the book"Aparecer ou Sumir" / "Be Seen or Be Forgotten"."""from __future__ import annotationsimport osfrom typing import Dict, List, Optional# ---------------------------------------------------------------------------# TODO: credential — all values come from the environment. Nothing is hardcoded.# Set these before any real GBP call can succeed.# ---------------------------------------------------------------------------# TODO: credential — OAuth client id/secret for a Google Cloud project with the# "Business Profile API" (mybusinessbusinessinformation) enabled.GBP_OAUTH_CLIENT_ID = os.environ.get("GBP_OAUTH_CLIENT_ID", "")GBP_OAUTH_CLIENT_SECRET = os.environ.get("GBP_OAUTH_CLIENT_SECRET", "")# TODO: credential — a long-lived OAuth refresh token for the managing account.GBP_OAUTH_REFRESH_TOKEN = os.environ.get("GBP_OAUTH_REFRESH_TOKEN", "")# TODO: credential — the account resource name, e.g. "accounts/1234567890".GBP_ACCOUNT_ID = os.environ.get("GBP_ACCOUNT_ID", "")# OAuth scope needed to read and write business information.GBP_SCOPE = "https://www.googleapis.com/auth/business.manage"LocationPatch = Dict[str, object]def _have_oauth() -> bool: """True only when every OAuth credential is present in the environment.""" return bool( GBP_OAUTH_CLIENT_ID and GBP_OAUTH_CLIENT_SECRET and GBP_OAUTH_REFRESH_TOKEN and GBP_ACCOUNT_ID )def authenticate() -> object: """Build an authorized GBP API session from the OAuth credentials. TODO: credential — steps once you have an OAuth client + refresh token: 1. pip install google-api-python-client google-auth google-auth-oauthlib 2. Exchange the refresh token for an access token:: from google.oauth2.credentials import Credentials creds = Credentials( None, refresh_token=GBP_OAUTH_REFRESH_TOKEN, client_id=GBP_OAUTH_CLIENT_ID, client_secret=GBP_OAUTH_CLIENT_SECRET, token_uri="https://oauth2.googleapis.com/token", scopes=[GBP_SCOPE], ) 3. Build the service:: from googleapiclient.discovery import build service = build("mybusinessbusinessinformation", "v1", credentials=creds) and return it for ``list_locations`` / ``update_location`` to use. """ if not _have_oauth(): print( "[gmn_sync] Missing OAuth credentials. Set GBP_OAUTH_CLIENT_ID, " "GBP_OAUTH_CLIENT_SECRET, GBP_OAUTH_REFRESH_TOKEN and GBP_ACCOUNT_ID " "in your environment. See the authenticate() docstring for the " "full setup steps." ) raise NotImplementedError( "authenticate() needs the four GBP_OAUTH_* / GBP_ACCOUNT_ID env vars." ) # TODO: credential — replace with the real Credentials + build() call. raise NotImplementedError("GBP OAuth session build not implemented yet.")def list_locations(service: Optional[object] = None) -> List[Dict[str, object]]: """List the business locations managed under ``GBP_ACCOUNT_ID``. TODO: credential — with an authorized ``service`` from ``authenticate()``:: resp = service.accounts().locations().list( parent=GBP_ACCOUNT_ID, readMask="name,title,categories,phoneNumbers,websiteUri,regularHours", ).execute() return resp.get("locations", []) Each returned location has a ``name`` like "locations/0987654321" that you pass to :func:`update_location`. """ if service is None and not _have_oauth(): print( "[gmn_sync] Cannot list locations without an authenticated session. " "Call authenticate() once the GBP_OAUTH_* env vars are set." ) raise NotImplementedError( "list_locations() needs an authenticated GBP session." ) # TODO: credential — replace with the real accounts().locations().list() call. raise NotImplementedError("GBP locations.list not implemented yet.")def update_location( location_id: str, patch: LocationPatch, service: Optional[object] = None,) -> Dict[str, object]: """Push a partial update (name, categories, hours, phone, website) to GBP. Args: location_id: resource name of the location, e.g. "locations/0987654321". patch: the fields to change. Only keys present are written, e.g.:: { "title": "Padaria do Bairro", "phoneNumbers": {"primaryPhone": "+55 51 99999-0000"}, "websiteUri": "https://padariadobairro.com.br", "categories": {"primaryCategory": {"name": "categories/bakery"}}, "regularHours": {...}, } service: an authorized session from :func:`authenticate` (optional; if omitted the function will require the OAuth env vars to be present). TODO: credential — the real write is a PATCH with an updateMask listing only the fields you changed:: update_mask = ",".join(patch.keys()) return service.locations().patch( name=location_id, updateMask=update_mask, body=patch, ).execute() GBP validates edits and some categories/hours changes may be reviewed by Google before going live. """ if not location_id: raise ValueError("location_id is required, e.g. 'locations/0987654321'.") if not isinstance(patch, dict) or not patch: raise ValueError("patch must be a non-empty dict of fields to update.") if service is None and not _have_oauth(): print( "[gmn_sync] Cannot update location without an authenticated session. " f"Would PATCH {location_id} with fields: {', '.join(sorted(patch))}. " "Set the GBP_OAUTH_* env vars and call authenticate() first." ) raise NotImplementedError( "update_location() needs an authenticated GBP session." ) # TODO: credential — replace with the real locations().patch() call. raise NotImplementedError("GBP locations.patch not implemented yet.")def main() -> None: """CLI entry point. Runs the credential-gated sync flow end to end. Without credentials it prints guidance and exits cleanly (no traceback), so the module is safe to run as a smoke test. """ print("Google Meu Negócio / Google Business Profile sync") print("-" * 52) if not _have_oauth(): print( "No GBP credentials found in the environment.\n" "Set GBP_OAUTH_CLIENT_ID, GBP_OAUTH_CLIENT_SECRET, " "GBP_OAUTH_REFRESH_TOKEN and GBP_ACCOUNT_ID, then re-run.\n" "To try the companion review pipeline offline instead, run:\n" " python3 reviews_collector.py --demo\n" " python3 demo.py" ) return # With credentials present, wire the real flow (still stubbed until the API # calls above are implemented). service = authenticate() for location in list_locations(service): print("location:", location.get("name"), "-", location.get("title"))if __name__ == "__main__": main()Dúvidas
Perguntas frequentes
Preciso de credenciais para rodar?
Só para os dados reais. O demo.py e o reviews_collector.py --demo rodam ponta a ponta com avaliações fictícias, sem tocar em nenhuma API. Apenas o gmn_sync.py (sync da ficha) e a função fetch_reviews() (coleta ao vivo) precisam de OAuth do Google Business Profile: GBP_OAUTH_CLIENT_ID, GBP_OAUTH_CLIENT_SECRET, GBP_OAUTH_REFRESH_TOKEN e GBP_ACCOUNT_ID — todas lidas do ambiente, nada hardcoded.
Com que frequência devo sincronizar a ficha e coletar avaliações?
Informações da ficha (nome, categorias, horário, telefone, site) mudam pouco — sincronize sob demanda, quando algo muda, ou uma vez por dia. As avaliações chegam o tempo todo, então vale coletar de hora em hora ou algumas vezes ao dia. Como a ingestão é idempotente (chave review_id), rodar com frequência não duplica nada.
O texto das avaliações fica armazenado?
Sim. Cada avaliação vai para uma tabela SQLite reviews(review_id, author, star_rating, text, create_time). O texto é opcional (algumas avaliações só têm estrela) e o cálculo da aggregateRating usa apenas star_rating. O JSON-LD inclui o reviewBody quando há comentário.
Implementação
Prefere que eu implemente?
Conte o seu caso numa conversa e a gente define o escopo juntos.
Agendar conversa