<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Richard Nemeth</title>
    <description>The latest articles on DEV Community by Richard Nemeth (@hsdxpro).</description>
    <link>https://dev.to/hsdxpro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4131610%2F5679ba95-b91b-4348-bab8-8ff61c948111.jpg</url>
      <title>DEV Community: Richard Nemeth</title>
      <link>https://dev.to/hsdxpro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hsdxpro"/>
    <language>en</language>
    <item>
      <title>How to get Google News results as structured JSON</title>
      <dc:creator>Richard Nemeth</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:20:07 +0000</pubDate>
      <link>https://dev.to/answerline/how-to-get-google-news-results-as-structured-json-2pj</link>
      <guid>https://dev.to/answerline/how-to-get-google-news-results-as-structured-json-2pj</guid>
      <description>&lt;p&gt;The &lt;a href="https://answerline.dev/engines/google-news" rel="noopener noreferrer"&gt;Google News API&lt;/a&gt; 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).&lt;/p&gt;

&lt;p&gt;If you are deciding between RSS feeds and structured results, read &lt;a href="https://answerline.dev/blog/google-news-rss" rel="noopener noreferrer"&gt;Google News RSS feeds: what they give you and where they stop&lt;/a&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  The request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.answerline.dev/v1/monitor/google/news &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ANSWERLINE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "query": "acme corp recall",
    "country": "DE",
    "hl": "de",
    "pages": 2
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request fields:&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  The response
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"newsResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp expands recall to second product line"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"link"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://news.example.de/acme-recall"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"snippet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The company said on Tuesday that..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Nachrichten"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3 hours ago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"thumbnail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://news.example.de/images/acme.jpg"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Field by field:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Normalizing dates
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;date&lt;/code&gt; is display text, such as &lt;code&gt;"2 hours ago"&lt;/code&gt;, so do not assume one format. Store three things: the raw string, the capture time, and a parsed estimate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;UNITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;week&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weeks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;published_estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;captured_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Parse English relative dates like &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3 hours ago&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Returns None for formats it doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t know.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)\s+(minute|hour|day|week)s?\s+ago&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;captured_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;UNITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))})&lt;/span&gt;

&lt;span class="n"&gt;captured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;published_estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 hours ago&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Display text can depend on the interface language you request with &lt;code&gt;hl&lt;/code&gt;, so a parser written for English may not match other languages. Rather than parse every format, keep &lt;code&gt;None&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing and deduplicating articles
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;news_articles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;url_key&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- normalized link&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;first_seen&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;                &lt;span class="c1"&gt;-- estimate, nullable&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;news_observations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;run_id&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;country&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;url_key&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;news_articles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url_key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;position&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;    &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;       &lt;span class="nb"&gt;int&lt;/span&gt;    &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;date_raw&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;captured_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;With this split, "new article" is an insert into &lt;code&gt;news_articles&lt;/code&gt;, and "article moved up" is a comparison of &lt;code&gt;position&lt;/code&gt; between two observations of the same &lt;code&gt;url_key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing queries that return what you mean
&lt;/h2&gt;

&lt;p&gt;The query is the largest source of noise in news monitoring. Quotes for exact matches and &lt;code&gt;-&lt;/code&gt; for exclusions are both documented in Google's &lt;a href="https://support.google.com/websearch/answer/2466433" rel="noopener noreferrer"&gt;Refine web searches&lt;/a&gt; help page (checked 2026-09-17). A few rules keep result sets reviewable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quote multi-word names.&lt;/strong&gt; &lt;code&gt;"acme corp"&lt;/code&gt; asks for the exact phrase; without quotes the two words are searched as separate terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude known collisions.&lt;/strong&gt; If your brand shares a name with a place, a film or a sports team, add &lt;code&gt;-&lt;/code&gt; terms for the collision (&lt;code&gt;"acme" -cartoon&lt;/code&gt;) and review what the exclusion removes on the first few runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One intent per query.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the market's language.&lt;/strong&gt; Send local-language names and terms with the matching &lt;code&gt;hl&lt;/code&gt; for each market; a translated English query can miss how local outlets write about you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the query list.&lt;/strong&gt; Store a version with every observation, so a change in results can be separated from a change in the query.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pattern 1: brand and competitor alerts
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Run each query per market as an async task on a schedule.&lt;/li&gt;
&lt;li&gt;On each webhook, insert new articles; alert on inserts whose &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;snippet&lt;/code&gt; matches the entity.&lt;/li&gt;
&lt;li&gt;Suppress repeats by &lt;code&gt;url_key&lt;/code&gt;, and group alerts by &lt;code&gt;source&lt;/code&gt; so one story syndicated across many outlets arrives as one message with a list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Quoted phrases and operators such as &lt;code&gt;-&lt;/code&gt; go inside &lt;code&gt;query&lt;/code&gt; exactly as you would type them into Google. &lt;a href="https://answerline.dev/blog/google-search-operators" rel="noopener noreferrer"&gt;Google search operators&lt;/a&gt; lists the ones worth using.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: coverage share
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publisher_share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;results: newsResults items from one run of a query set.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;most_common&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: adverse media screening
&lt;/h2&gt;

&lt;p&gt;Compliance and risk teams screen names against negative news. The shape:&lt;/p&gt;

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

&lt;p&gt;The &lt;a href="https://answerline.dev/use-cases/adverse-media-screening" rel="noopener noreferrer"&gt;adverse media screening use case&lt;/a&gt; describes the workflow end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling
&lt;/h2&gt;

&lt;p&gt;News moves quickly, but not every query needs the same frequency. A practical split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query type&lt;/th&gt;
&lt;th&gt;Suggested interval&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Active incident or launch&lt;/td&gt;
&lt;td&gt;15-60 minutes&lt;/td&gt;
&lt;td&gt;Pickup speed matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand and executive names&lt;/td&gt;
&lt;td&gt;Every few hours&lt;/td&gt;
&lt;td&gt;Catch stories the same day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic and competitor coverage&lt;/td&gt;
&lt;td&gt;Daily&lt;/td&gt;
&lt;td&gt;Share changes slowly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screening of third parties&lt;/td&gt;
&lt;td&gt;Daily or weekly&lt;/td&gt;
&lt;td&gt;Driven by policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Send each run as one batch of async tasks (up to 500 per request) with a webhook, so nothing polls. Use an &lt;code&gt;idempotencyKey&lt;/code&gt; built from the run slot, query id and market, so a retried scheduler run creates no duplicates. See &lt;a href="https://answerline.dev/blog/sync-async-webhooks" rel="noopener noreferrer"&gt;sync, async and webhooks&lt;/a&gt; and &lt;a href="https://answerline.dev/docs/async" rel="noopener noreferrer"&gt;async tasks&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.answerline.dev/v1/async/task/batch &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ANSWERLINE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'[
    { "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" } }
  ]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cost in credits
&lt;/h2&gt;

&lt;p&gt;From the current credit table (see &lt;a href="https://answerline.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for credit prices):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Credits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google News task, 1 page&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News task, 3 pages&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News synchronous call, 1 page&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

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

&lt;p&gt;Field reference: &lt;a href="https://answerline.dev/engines/google-news" rel="noopener noreferrer"&gt;Google News engine page&lt;/a&gt;. More patterns: &lt;a href="https://answerline.dev/use-cases/news-monitoring" rel="noopener noreferrer"&gt;news monitoring&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>news</category>
    </item>
    <item>
      <title>What in-house AI visibility tracking costs to run</title>
      <dc:creator>Richard Nemeth</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:16:05 +0000</pubDate>
      <link>https://dev.to/answerline/what-in-house-ai-visibility-tracking-costs-to-run-3h17</link>
      <guid>https://dev.to/answerline/what-in-house-ai-visibility-tracking-costs-to-run-3h17</guid>
      <description>&lt;p&gt;The cost of collecting AI answers in-house is rarely the API bill you avoid. It is the engineering time to reach production quality on each engine, plus the maintenance that follows for as long as you run it. Building is the right call when the collection layer is itself your product or you need data no provider returns; the &lt;a href="https://answerline.dev/compare/build-vs-buy" rel="noopener noreferrer"&gt;build vs buy&lt;/a&gt; comparison covers that decision, and the framework below fills in the numbers behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collection layer&lt;/strong&gt;: everything between "we have a prompt" and "we have a structured record of what the engine answered": execution, parsing, retries and storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analysis layer&lt;/strong&gt;: what you build on top: share of voice, citation tracking, competitor reports, alerts. This is where customers compare products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run&lt;/strong&gt;: one prompt, on one engine, in one market, collected once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What building involves
&lt;/h2&gt;

&lt;p&gt;Consumer AI products such as ChatGPT, Perplexity, Gemini, Copilot, Grok and Google AI Mode show answers that include web search, citations, product cards and local results. Those surfaces live in the consumer applications, not in the model providers' developer APIs. Each of the components below exists once per engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Collection runs
&lt;/h3&gt;

&lt;p&gt;You run automated collection against each product and keep throughput inside what each product allows. That is infrastructure to provision, scale, patch and monitor, sized for your peak run.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Parsing that keeps up with interface changes
&lt;/h3&gt;

&lt;p&gt;Each engine renders answers differently: inline citations, grouped sources, shopping cards, maps, follow-up queries. Each needs its own parser, a fixture set, and tests. When an interface changes, the failure can be silent: the run succeeds and a field such as &lt;code&gt;sources&lt;/code&gt; comes back empty. Catching that takes data-quality monitoring per field, per engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Market coverage
&lt;/h3&gt;

&lt;p&gt;Answers differ by country, and for local intent by region. Reporting per market requires collecting from each market and validating that the stored answers reflect it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Queueing, retries and storage
&lt;/h3&gt;

&lt;p&gt;At volume, partial failure is the normal case. You need a queue with priorities, retries that cannot create duplicates, per-run deadlines, result storage and a way to push results to downstream systems. That is a distributed-systems project in its own right.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Terms, compliance and review
&lt;/h3&gt;

&lt;p&gt;Each consumer product has its own terms of service. Collecting from them at volume is a decision for your legal and compliance review, per engine, before engineering starts, and again when terms change.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. On-call
&lt;/h3&gt;

&lt;p&gt;Interfaces change without notice and on no schedule. Somebody owns the pager for each engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cost framework you can fill in
&lt;/h2&gt;

&lt;p&gt;Team cost, engine count and quality bar vary too much for universal numbers. Fill in this table with your own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Your number&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;Engines you need&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Engineer-months to reach production quality on one engine&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M&lt;/td&gt;
&lt;td&gt;Engineer-months of maintenance per engine, per month&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Loaded cost of one engineer-month&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I&lt;/td&gt;
&lt;td&gt;Monthly infrastructure: compute, storage, monitoring&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R&lt;/td&gt;
&lt;td&gt;One-off review and compliance cost&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T&lt;/td&gt;
&lt;td&gt;Months you plan to run it&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build, cost over T months = &lt;code&gt;E × B × C + E × M × C × T + I × T + R&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Buy, cost over T months = the monthly price of the plan that covers your credits (below) × T&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adjust for three effects the formulas miss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build is paid before value. &lt;code&gt;E × B&lt;/code&gt; months pass before the first reliable dataset; for a product that competes on time to market, that delay has its own cost.&lt;/li&gt;
&lt;li&gt;Maintenance scales with engines, not volume. A fifth engine adds a fifth parser and a fifth pager rotation, even if you run few prompts on it.&lt;/li&gt;
&lt;li&gt;Opportunity cost. Every engineer-month in the collection layer is one not spent on the analysis layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Computing the buy side
&lt;/h2&gt;

&lt;p&gt;Buying is priced in credits per request. As async tasks, the base price per request is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Credits per request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copilot&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google AI Mode&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Search&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Perplexity and Grok are paused for now and coming back soon; requests to them are refused and not billed. Synchronous calls add 2 credits each. Some options add to the price: on ChatGPT, +2 once if &lt;code&gt;include.rawResponse&lt;/code&gt; or &lt;code&gt;shopping&lt;/code&gt; is on (&lt;code&gt;searchQueries&lt;/code&gt; and &lt;code&gt;ads&lt;/code&gt; are free); on AI Mode, +1 per expanded product cluster, up to 6; on Google Search, +2 per extra page and +2 once for the AI Overview; on Google News, +2 per extra page. Credits are reserved at the maximum and charged at the actual cost on success; failed requests are charged nothing, and queued tasks you clear are never charged. The details are in &lt;a href="https://answerline.dev/docs/credits" rel="noopener noreferrer"&gt;credits&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worked example 1: a large program
&lt;/h3&gt;

&lt;p&gt;20,000 prompts on ChatGPT and Gemini in the US, plus 2,000 Google News queries in 10 markets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs per sweep: 20,000 × 2 + 2,000 × 10 = 60,000.&lt;/li&gt;
&lt;li&gt;Credits for the assistants: 20,000 × (5 + 4) = 180,000.&lt;/li&gt;
&lt;li&gt;Credits for Google News: 2,000 × 10 × 2 = 40,000.&lt;/li&gt;
&lt;li&gt;Credits per full sweep, async, no add-ons: 180,000 + 40,000 = 220,000.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A weekly sweep (4 a month) is 880,000 credits, inside the Growth plan's 1,350,000. A daily sweep is 6,600,000 a month and needs an Enterprise plan. Plan prices and a volume estimator are on &lt;a href="https://answerline.dev/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worked example 2: a focused program
&lt;/h3&gt;

&lt;p&gt;200 prompts on ChatGPT and Gemini in the US, plus 50 Google News queries in 3 markets, weekly (4 runs a month):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credits per prompt: 5 + 4 = 9.&lt;/li&gt;
&lt;li&gt;Per sweep: 200 × 9 + 50 × 3 × 2 = 1,800 + 300 = 2,100 credits.&lt;/li&gt;
&lt;li&gt;Per month: 2,100 × 4 = 8,400 credits, inside the Lite plan's 37,500.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unused monthly credits do not roll over, so size the plan to your steady cadence, not your peak. The &lt;a href="https://answerline.dev/blog/ai-monitoring-cost-planning" rel="noopener noreferrer"&gt;cost planning guide&lt;/a&gt; covers sampling and cadence choices that change these numbers, and &lt;a href="https://answerline.dev/blog/sampling-vs-census-ai-monitoring" rel="noopener noreferrer"&gt;sampling vs census&lt;/a&gt; covers when you can run fewer prompts without losing the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the buy side returns
&lt;/h2&gt;

&lt;p&gt;Compare the output as well as the price. Each engine returns structured JSON under &lt;code&gt;result&lt;/code&gt;, with fields that differ by engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All AI engines: &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;sources[]&lt;/code&gt;, and &lt;code&gt;markdown&lt;/code&gt; with &lt;code&gt;include.markdown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;ChatGPT: &lt;code&gt;citationPills[]&lt;/code&gt;, &lt;code&gt;entities[]&lt;/code&gt;, &lt;code&gt;shoppingCards[]&lt;/code&gt; (with &lt;code&gt;include.shopping&lt;/code&gt;), &lt;code&gt;map[]&lt;/code&gt;. The &lt;code&gt;searchQueries[]&lt;/code&gt; (free with &lt;code&gt;include.searchQueries&lt;/code&gt;), &lt;code&gt;inlineProducts[]&lt;/code&gt; (with &lt;code&gt;include.shopping&lt;/code&gt;) and &lt;code&gt;ads[]&lt;/code&gt; (with &lt;code&gt;include.ads&lt;/code&gt;) fields exist but come back empty on the answers served today.&lt;/li&gt;
&lt;li&gt;Gemini: &lt;code&gt;citationPills[]&lt;/code&gt;, &lt;code&gt;places[]&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For volume, tasks go through &lt;code&gt;POST /v1/async/task/batch&lt;/code&gt; (up to 500 per request) with idempotency keys and webhooks. Retries, deadlines and queueing are part of the API contract rather than code you maintain; see &lt;a href="https://answerline.dev/docs/async" rel="noopener noreferrer"&gt;async tasks&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you still build when you buy
&lt;/h2&gt;

&lt;p&gt;Buying the collection layer moves engineering to the parts that differ between products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prompt set and schedule. Which prompts, engines and markets, and how often. &lt;a href="https://answerline.dev/blog/prompt-set-design" rel="noopener noreferrer"&gt;Prompt set design&lt;/a&gt; and &lt;a href="https://answerline.dev/blog/ai-monitoring-cadence" rel="noopener noreferrer"&gt;monitoring cadence&lt;/a&gt; cover the choices.&lt;/li&gt;
&lt;li&gt;Submission and retries. Batching, idempotency keys derived from each task's meaning, and a retry policy that never duplicates work; see &lt;a href="https://answerline.dev/blog/idempotency-keys-explained" rel="noopener noreferrer"&gt;idempotency keys&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A webhook receiver. Signature verification over the raw body, deduplication by &lt;code&gt;task.id&lt;/code&gt;, and storage; see &lt;a href="https://answerline.dev/blog/verify-webhook-signatures" rel="noopener noreferrer"&gt;verifying webhook signatures&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Normalization. Field names differ by engine (&lt;code&gt;shopping_cards&lt;/code&gt; on Perplexity, &lt;code&gt;shoppingCards&lt;/code&gt; on ChatGPT; citation ids 0-based on ChatGPT and 1-based elsewhere), so map them into your own schema once.&lt;/li&gt;
&lt;li&gt;The analysis layer. Mentions, citations, share of voice and reports, which is what customers pay for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SDKs and workflow integrations are listed under &lt;a href="https://answerline.dev/docs/integrations" rel="noopener noreferrer"&gt;integrations&lt;/a&gt;; for AI agents, the &lt;a href="https://answerline.dev/integrations/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; exposes each engine as a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision criteria
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Lean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Answers feed an analytics, SEO or visibility product&lt;/td&gt;
&lt;td&gt;Buy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need several engines and several markets&lt;/td&gt;
&lt;td&gt;Buy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first reliable dataset matters&lt;/td&gt;
&lt;td&gt;Buy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collection itself is the product you sell&lt;/td&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need a field no provider returns&lt;/td&gt;
&lt;td&gt;Build that part, scoped to one engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One engine, low volume, and spare engineering capacity&lt;/td&gt;
&lt;td&gt;Either; compare with the framework&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A hybrid also works: buy the engines and markets a provider covers, and build narrowly where you need something specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to evaluate in a week
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick your ten hardest prompts: the ones with shopping, local or citation-heavy answers that break parsers.&lt;/li&gt;
&lt;li&gt;Run them on every engine you need. The free tier (500 credits, once, no card) works on every live engine, so you can evaluate the assistants before paying: 10 prompts as synchronous calls on ChatGPT and Gemini cost 10 × (9 + 2 × 2) = 130 credits. As async tasks, 10 × 9 = 90.&lt;/li&gt;
&lt;li&gt;Compare field by field against what your pipeline or manual checks show: citations present, ordering, product cards, places.&lt;/li&gt;
&lt;li&gt;Check the failure path: send a malformed request and a burst of requests, and confirm your code handles 422, 429 and batch item errors as described in &lt;a href="https://answerline.dev/blog/api-errors-and-retries" rel="noopener noreferrer"&gt;the errors post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Price your volume with the credit math above and your cadence.&lt;/li&gt;
&lt;li&gt;Fill in the build table with your own team's estimates and compare.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pitfalls in the comparison
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Comparing one engine built to several engines bought. Price the scope you need.&lt;/li&gt;
&lt;li&gt;Ignoring maintenance. First-version cost is the smaller term over any horizon longer than a few months.&lt;/li&gt;
&lt;li&gt;Treating developer model APIs as equivalent. A completion from a model API does not include the consumer app's web search, citations or product cards; see &lt;a href="https://answerline.dev/compare/provider-apis" rel="noopener noreferrer"&gt;provider APIs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Pricing at peak cadence. Credits do not roll over; plan for the steady state.&lt;/li&gt;
&lt;li&gt;Skipping the terms review. It belongs at the start of a build plan, not after launch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table version of this comparison is at &lt;a href="https://answerline.dev/compare/build-vs-buy" rel="noopener noreferrer"&gt;build vs buy&lt;/a&gt;; to run your first prompts, start with the &lt;a href="https://answerline.dev/docs/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
  </channel>
</rss>
