DEV Community

Richard Nemeth for AnswerLine

Posted on Originally published at answerline.dev

How to get Google News results as structured JSON

The Google News API takes a query and returns the articles Google News shows for it, in order, as data: headline, publisher, link, snippet, date and thumbnail, for a chosen country and language. Below: the request and response, date normalization, storage and deduplication, and three patterns (brand alerts, coverage share, adverse media screening).

If you are deciding between RSS feeds and structured results, read Google News RSS feeds: what they give you and where they stop first.

Definitions

  • News result: one article on the Google News results page for a query, returned as an item of result.newsResults[].
  • Position: the 1-indexed order of the article in the news results, returned with the page it appeared on.
  • Source: the publisher name as Google displays it, such as a newspaper or a trade site.
  • Coverage share: the fraction of news results for a query set that belong to a given publisher or mention a given brand.

The request

curl -X POST https://api.answerline.dev/v1/monitor/google/news \
  -H "Authorization: Bearer $ANSWERLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "acme corp recall",
    "country": "DE",
    "hl": "de",
    "pages": 2
  }'
Enter fullscreen mode Exit fullscreen mode

Request fields:

Field Type Notes
query string, 1-10,000 chars Required. The News search.
country string ISO 3166-1 alpha-2. Send country or gl.
gl string Google's name for the result geography; same codes, either case. Different values in country and gl are a 400.
hl string Interface language (en, de, pt-BR). Overrides the language derived from the country.
pages integer 1-10 Default 1.
include.html boolean Not available on Google News: a request that sets it is refused with a 400.

News targeting is by country and language. Supported countries are listed by GET /v1/countries?model=google. Don't route News through the Google Search endpoint's url shape: a Google URL carrying tbm=nws is rejected there, because News has its own endpoint and pricing.

The response

{
  "success": true,
  "result": {
    "newsResults": [
      {
        "position": 1,
        "title": "Acme Corp expands recall to second product line",
        "link": "https://news.example.de/acme-recall",
        "snippet": "The company said on Tuesday that...",
        "source": "Example Nachrichten",
        "date": "3 hours ago",
        "page": 1,
        "thumbnail": "https://news.example.de/images/acme.jpg"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Field by field:

  • position: 1-indexed rank in the news results. With pages above 1, page tells you which page the article came from; store both.
  • title, snippet: the headline and the text excerpt shown.
  • link: the article URL. If Google served it as a redirect, redirectLink carries Google's redirect next to it.
  • source: the publisher's display name. It is a name, not a domain; derive the domain from link when you need a stable key.
  • date: the date text as displayed, for example "2 hours ago".
  • thumbnail: the image URL when one is shown.
  • html[]: not returned for Google News; include.html is refused on this endpoint.

Normalizing dates

date is display text, such as "2 hours ago", so do not assume one format. Store three things: the raw string, the capture time, and a parsed estimate.

import re
from datetime import datetime, timedelta, timezone

UNITS = {"minute": "minutes", "hour": "hours", "day": "days", "week": "weeks"}

def published_estimate(raw: str, captured_at: datetime) -> datetime | None:
    """Parse English relative dates like '3 hours ago'. Returns None for formats it doesn't know."""
    m = re.match(r"(\d+)\s+(minute|hour|day|week)s?\s+ago", raw.strip().lower())
    if not m:
        return None
    return captured_at - timedelta(**{UNITS[m.group(2)]: int(m.group(1))})

captured = datetime.now(timezone.utc)
print(published_estimate("3 hours ago", captured))
Enter fullscreen mode Exit fullscreen mode

Display text can depend on the interface language you request with hl, so a parser written for English may not match other languages. Rather than parse every format, keep None for the unknown ones and fall back to the first time you saw the article: for alerting, "first seen" is usually the timestamp that matters anyway.

Storing and deduplicating articles

The same article comes back on every run while it stays in the results. Split the data into what an article is and where it appeared:

create table news_articles (
  url_key      text primary key,          -- normalized link
  title        text not null,
  source       text,
  first_seen   timestamptz not null,
  published_at timestamptz                -- estimate, nullable
);

create table news_observations (
  run_id     bigint not null,
  query      text   not null,
  country    text   not null,
  url_key    text   not null references news_articles(url_key),
  position   int    not null,
  page       int    not null,
  date_raw   text,
  captured_at timestamptz not null,
  primary key (run_id, query, country, url_key)
);
Enter fullscreen mode Exit fullscreen mode

Normalize link into url_key by lowercasing the host, dropping www., removing the fragment and common tracking parameters (utm_*), and trimming a trailing slash. Do not strip all query parameters: some publishers identify articles by them.

With this split, "new article" is an insert into news_articles, and "article moved up" is a comparison of position between two observations of the same url_key.

Writing queries that return what you mean

The query is the largest source of noise in news monitoring. Quotes for exact matches and - for exclusions are both documented in Google's Refine web searches help page (checked 2026-09-17). A few rules keep result sets reviewable:

  1. Quote multi-word names. "acme corp" asks for the exact phrase; without quotes the two words are searched as separate terms.
  2. Exclude known collisions. If your brand shares a name with a place, a film or a sports team, add - terms for the collision ("acme" -cartoon) and review what the exclusion removes on the first few runs.
  3. One intent per query. Keep "brand news", "brand plus product", and "brand plus risk terms" as separate queries so each result set answers one question and can have its own schedule.
  4. Match the market's language. Send local-language names and terms with the matching hl for each market; a translated English query can miss how local outlets write about you.
  5. Version the query list. Store a version with every observation, so a change in results can be separated from a change in the query.

Pattern 1: brand and competitor alerts

  1. Write one query per entity and variant: the brand name in quotes, the brand plus key product names, and the executives' names if they are public figures.
  2. Run each query per market as an async task on a schedule.
  3. On each webhook, insert new articles; alert on inserts whose title or snippet matches the entity.
  4. Suppress repeats by url_key, and group alerts by source so one story syndicated across many outlets arrives as one message with a list.

Quoted phrases and operators such as - go inside query exactly as you would type them into Google. Google search operators lists the ones worth using.

Pattern 2: coverage share

Coverage share answers "who owns the news results for our topics?". For a fixed query set, count results per publisher domain or per brand mention and divide by all results:

from collections import Counter
from urllib.parse import urlparse

def publisher_share(results: list[dict], top_n: int = 10) -> list[tuple[str, float]]:
    """results: newsResults items from one run of a query set."""
    domains = Counter((urlparse(r["link"]).hostname or "").removeprefix("www.")
                      for r in results if r.get("page") == 1 and r.get("position", 99) <= top_n)
    total = sum(domains.values()) or 1
    return [(d, n / total) for d, n in domains.most_common()]
Enter fullscreen mode Exit fullscreen mode

Limit to the first page or the top 10 positions so a query with more results does not dominate. Track the share weekly: a PR team sees whether coverage is concentrated in a few outlets, and a publisher sees which competitors win the topics it covers.

Pattern 3: adverse media screening

Compliance and risk teams screen names against negative news. The shape:

  1. For each entity, combine the name with risk terms in separate queries ("Jane Example" fraud, "Jane Example" lawsuit), because a long OR chain returns a mixed set that is harder to review.
  2. Run in each country where the entity operates, with the local hl, since local-language coverage often appears there first.
  3. Store every observation, not only matches: an auditor may ask what the screen returned on a given date.
  4. Route matches to a reviewer; do not auto-decide on a headline.

The adverse media screening use case describes the workflow end to end.

Scheduling

News moves quickly, but not every query needs the same frequency. A practical split:

Query type Suggested interval Why
Active incident or launch 15-60 minutes Pickup speed matters
Brand and executive names Every few hours Catch stories the same day
Topic and competitor coverage Daily Share changes slowly
Screening of third parties Daily or weekly Driven by policy

Send each run as one batch of async tasks (up to 500 per request) with a webhook, so nothing polls. Use an idempotencyKey built from the run slot, query id and market, so a retried scheduler run creates no duplicates. See sync, async and webhooks and async tasks.

curl -X POST https://api.answerline.dev/v1/async/task/batch \
  -H "Authorization: Bearer $ANSWERLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    { "taskType": "GOOGLE_NEWS", "idempotencyKey": "news-2026-09-15T10-acme-de",
      "payload": { "query": "\"acme corp\"", "country": "DE", "hl": "de" },
      "webhook": { "url": "https://your-app.example/hooks/news" } },
    { "taskType": "GOOGLE_NEWS", "idempotencyKey": "news-2026-09-15T10-acme-us",
      "payload": { "query": "\"acme corp\"", "country": "US" },
      "webhook": { "url": "https://your-app.example/hooks/news" } }
  ]'
Enter fullscreen mode Exit fullscreen mode

Cost in credits

From the current credit table (see /pricing for credit prices):

Request Credits
Google News task, 1 page 2
Google News task, 3 pages 6
Google News synchronous call, 1 page 4

A budget is queries × markets × runs per month × credits per run. 50 queries in 2 markets, hourly (about 720 runs a month), one page as async tasks: 50 × 2 × 720 × 2 = 144,000 credits. The same set every 6 hours is 24,000. Frequency is the largest lever, so match it to the table above rather than running everything at the fastest interval.

Pitfalls

  1. Treating source as a key. Publisher display names change and collide. Key on the normalized link domain.
  2. Parsing dates in one language. The date text follows hl. Keep the raw string and a first-seen timestamp.
  3. Alerting on every run. Alert on new url_key inserts, not on every observation.
  4. Using long OR chains. Split risk terms into separate queries so each result set is reviewable.

Field reference: Google News engine page. More patterns: news monitoring.

Top comments (0)