<?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: datalume</title>
    <description>The latest articles on DEV Community by datalume (@datalume).</description>
    <link>https://dev.to/datalume</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%2F4206181%2F0ce52632-8101-48ff-a257-ec97fc716183.jpeg</url>
      <title>DEV Community: datalume</title>
      <link>https://dev.to/datalume</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/datalume"/>
    <language>en</language>
    <item>
      <title>How to Export Google Ads Transparency Center Data (with Ad Copy) in Python</title>
      <dc:creator>datalume</dc:creator>
      <pubDate>Sun, 01 Nov 2026 06:12:48 +0000</pubDate>
      <link>https://dev.to/datalume/how-to-export-google-ads-transparency-center-data-with-ad-copy-in-python-2h4a</link>
      <guid>https://dev.to/datalume/how-to-export-google-ads-transparency-center-data-with-ad-copy-in-python-2h4a</guid>
      <description>&lt;p&gt;I wanted something simple: &lt;strong&gt;every Google search ad a competitor is running, how long each one has been running, and the actual ad copy&lt;/strong&gt; — in a spreadsheet.&lt;/p&gt;

&lt;p&gt;Google already publishes all of this in the &lt;a href="https://adstransparency.google.com" rel="noopener noreferrer"&gt;Ads Transparency Center&lt;/a&gt; (Google's answer to Meta's Ad Library). It just has no export button and no API. So I scraped it, and ran into something I didn't expect: for most big advertisers, &lt;strong&gt;the ad copy isn't there as text at all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise: most text ads are stored as images
&lt;/h2&gt;

&lt;p&gt;When you open a text ad in the Transparency Center, Google renders it in one of two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A text preview&lt;/strong&gt; — the headline and description exist as text (in a small JS preview), easy to parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A screenshot&lt;/strong&gt; — a PNG of the ad, nothing else. The words only exist as pixels.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I pulled the first 30 text ads of five large advertisers on Nov 1, 2026 and counted how many had a text version:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Advertiser&lt;/th&gt;
&lt;th&gt;Text ads checked&lt;/th&gt;
&lt;th&gt;With a text version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;booking.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;expedia.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hubspot.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;samsung.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nike.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;150&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29 (19%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if a tool only reads the text preview, the best it can do here is copy for about &lt;strong&gt;1 ad in 5&lt;/strong&gt; — and for booking.com and expedia.com, nothing. That's a big hole if "what are my competitors saying?" is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the screenshots (the DIY version)
&lt;/h2&gt;

&lt;p&gt;The fix is OCR. Each ad record links to its snapshot image (&lt;code&gt;tpc.googlesyndication.com/archive/simgad/...&lt;/code&gt;), and a basic version with Tesseract is a few lines:&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;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pytesseract&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_ad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&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="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;# small text OCRs better when upscaled
&lt;/span&gt;    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pytesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;image_to_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--psm 6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pytesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DICT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&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="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;confidence&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;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets you a blob of text. Turning it into &lt;strong&gt;headline / description / display URL&lt;/strong&gt; reliably took most of the work. The traps I hit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Not everything in the image is the ad.&lt;/strong&gt; Many snapshots include a product thumbnail or sitelink buttons on the right. OCR happily reads those and mixes them into the description. Detect and crop them first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headline vs. description.&lt;/strong&gt; Google draws the headline in blue and the description in dark grey, so splitting by color before OCR works far better than guessing from line positions afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language.&lt;/strong&gt; The same advertiser runs ads in dozens of languages. Picking the Tesseract model from the countries the ad ran in (Korean, Japanese, Thai, Arabic, Cyrillic…) fixes most of the garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the confidence score — strictly.&lt;/strong&gt; My first threshold was 70. At 77, one booking.com description came out as &lt;em&gt;"Book a Hotel near Universal Studios. HEHHE HAHA Quick, Easy Booking."&lt;/em&gt; Above 90 the text was mostly clean, apart from cut-off words. Anything lower should be flagged, not trusted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some words are simply cut off.&lt;/strong&gt; The screenshot crops long headlines, so you'll get "Hotels in Downtown Chica…". Nothing to fix there — just know it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be gentle with Google.&lt;/strong&gt; The listing endpoints rate-limit quickly (429s and CAPTCHA pages). Retry a couple of times, then stop — don't hammer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The no-maintenance version
&lt;/h2&gt;

&lt;p&gt;I packaged all of this as an Apify Actor (&lt;a href="https://apify.com/datalume/google-ads-transparency-scraper" rel="noopener noreferrer"&gt;Google Ads Transparency &amp;amp; Ad Library Scraper&lt;/a&gt;), so I don't have to babysit OCR models or rate limits. Here's how I find a competitor's &lt;strong&gt;long-running&lt;/strong&gt; search ads — the ones they keep paying for:&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;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;YOUR_APIFY_TOKEN&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datalume/google-ads-transparency-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&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;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;nike.com&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;region&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;US&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;platform&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;search&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;format&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;text&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;minDaysRunning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# skip short tests
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sortBy&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;longestRunning&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;maxAdsPerTarget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_dataset_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;good&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;copyStatus&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# high-confidence copy only
&lt;/span&gt;&lt;span class="n"&gt;good&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daysShown&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;firstShown&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;headline&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;regionCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what came back for &lt;strong&gt;nike.com&lt;/strong&gt; (US, search, text ads, run on Nov 1, 2026) — 50 ads, copy for all 50 (38 from the text preview, 12 by OCR, all above 90 confidence). A slice of the longest-running ones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Days shown&lt;/th&gt;
&lt;th&gt;First shown&lt;/th&gt;
&lt;th&gt;Advertiser&lt;/th&gt;
&lt;th&gt;Headline&lt;/th&gt;
&lt;th&gt;Countries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,833&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;WPP Media (agency)&lt;/td&gt;
&lt;td&gt;Zapatillas Air Max 90. Nike MX&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,794&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;Nike Retail BV&lt;/td&gt;
&lt;td&gt;Nike - Onlineshop Deutschland&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,794&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;Nike Retail BV&lt;/td&gt;
&lt;td&gt;Nike: zapatillas, ropa y más&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,793&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;Nike Retail BV&lt;/td&gt;
&lt;td&gt;Nike UK - Online Store&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,792&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;Nike Retail BV&lt;/td&gt;
&lt;td&gt;The Official Nike Site - Nike: Shop The Collection &lt;em&gt;(OCR)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,735&lt;/td&gt;
&lt;td&gt;2021-10-25&lt;/td&gt;
&lt;td&gt;Nike Retail BV&lt;/td&gt;
&lt;td&gt;NIKE — onlinebutik - Officiella Nike-webbplatsen &lt;em&gt;(OCR)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,384&lt;/td&gt;
&lt;td&gt;2022-11-30&lt;/td&gt;
&lt;td&gt;Nike, Inc.&lt;/td&gt;
&lt;td&gt;Training &amp;amp; Gym Equipment. Nike.com &lt;em&gt;(OCR)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,384&lt;/td&gt;
&lt;td&gt;2022-11-30&lt;/td&gt;
&lt;td&gt;Nike, Inc.&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(dynamic — Google writes it)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things jump out in under a minute:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The longest-running ads weren't placed by Nike's own account&lt;/strong&gt; but by an agency account (WPP Media). A domain search returns every advertiser whose ads point at the domain — useful, but filter by advertiser if you only want the brand itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nike's European "brand" ads have been running since at least October 2021, in roughly 30–50 countries each&lt;/strong&gt;, with the same handful of lines ("The Official Website. Home Of Everything Nike. Shop The Latest Releases."). That's what a proven, evergreen brand ad looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the US, 28 of the 31 ads from the Nike, Inc. account have no fixed headline&lt;/strong&gt; — they're Dynamic Search Ads, where Google writes the headline from the landing page. Nike US leans on DSA and only controls the description. You'd never see that pattern by clicking through ads one at a time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also note &lt;code&gt;daysShown&lt;/code&gt; is the number of days the ad actually ran, not just last-minus-first: three ads that all "started" on 2021-10-25 show 1,833, 1,794 and 1,688 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else is in each row
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;firstShown&lt;/code&gt;, &lt;code&gt;lastShown&lt;/code&gt;, &lt;code&gt;daysShown&lt;/code&gt;, &lt;code&gt;isActive&lt;/code&gt; — how long the ad has been running and whether it's live&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;regions&lt;/code&gt; — every country the ad ran in, with the last date shown there&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;displayUrl&lt;/code&gt;, plus &lt;code&gt;copySource&lt;/code&gt; (&lt;code&gt;text&lt;/code&gt; or &lt;code&gt;ocr&lt;/code&gt;) and &lt;code&gt;copyConfidence&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;transparencyUrl&lt;/code&gt; — a link back to the ad on Google's site, so you can check anything by eye&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also pass advertiser names or IDs instead of domains, filter by country / platform / date, and set &lt;code&gt;onlyNewAds&lt;/code&gt; on a daily schedule to get just the new creatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats (read these before you trust the numbers)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;firstShown&lt;/code&gt; has a floor.&lt;/strong&gt; The oldest ads I've seen all show &lt;strong&gt;2021-10-25&lt;/strong&gt;, which looks like where the Transparency Center's history begins. Read it as "running since at least then", not as the real start date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR isn't perfect.&lt;/strong&gt; Even high-confidence rows can contain a wrong letter ("Al" for "AI") or a cut-off word. Low-confidence rows are kept but marked &lt;code&gt;lowConfidence&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain searches include other advertisers&lt;/strong&gt; — agencies, resellers, or anyone whose ads link to that domain or its subdomains (like the WPP Media rows above). Use an advertiser ID if you need exactly one advertiser.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;The Actor charges per ad: &lt;strong&gt;$2.00 per 1,000 ads&lt;/strong&gt;, or &lt;strong&gt;$3.50 per 1,000&lt;/strong&gt; for text ads where high-confidence copy was extracted (instead of, not on top of, the $2). Countries are included, and rows Google fails to load are free. The 50-ad run above cost &lt;strong&gt;$0.18&lt;/strong&gt; (50 ads with copy × $0.0035) and took about a minute; with &lt;code&gt;onlyNewAds&lt;/code&gt; on a schedule, you only pay for ads you haven't seen before.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I run datalume, the publisher of the Actor used here. Both the Actor and this article were built with AI assistance; every number above comes from real runs on Nov 1, 2026 and was checked against the raw data. Questions welcome in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>marketing</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I scraped Google Hotels for a competitor rate calendar — and found the trap that breaks most of them</title>
      <dc:creator>datalume</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:51:54 +0000</pubDate>
      <link>https://dev.to/datalume/i-scraped-google-hotels-for-a-competitor-rate-calendar-and-found-the-trap-that-breaks-most-of-them-5581</link>
      <guid>https://dev.to/datalume/i-scraped-google-hotels-for-a-competitor-rate-calendar-and-found-the-trap-that-breaks-most-of-them-5581</guid>
      <description>&lt;p&gt;I wanted a simple thing: &lt;strong&gt;the nightly price of 5 competing hotels for every date of the next few weeks&lt;/strong&gt;, plus which booking site sells each one cheapest. The kind of table a hotel revenue manager checks every morning.&lt;/p&gt;

&lt;p&gt;Google Hotels already shows all of this — it just doesn't let you export it. So I scraped it. Here's what I learned, including one trap that makes most "rate calendars" useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: Google re-ranks hotels for every date
&lt;/h2&gt;

&lt;p&gt;My first attempt was the obvious one: search "hotels in Shinjuku, Tokyo" for 7 consecutive check-in dates and keep the results.&lt;/p&gt;

&lt;p&gt;Then I checked how stable that list is. With the top 50 hotels per date over 7 dates, I got &lt;strong&gt;148 different hotels — and only 3 of them appeared on all 7 dates&lt;/strong&gt;. The same hotel can be #2 on one date, #42 on another, and missing on a third.&lt;/p&gt;

&lt;p&gt;That's fine for browsing, but for rate shopping it's fatal: your "calendar" ends up full of holes exactly where you need the comparison.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking "which hotels are in Shinjuku?" and ask "&lt;strong&gt;what does &lt;em&gt;this exact&lt;/em&gt; hotel cost on each date?&lt;/strong&gt;" — lock each competitor to its Google Hotels ID once, then look up that same ID for every check-in date.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup (Python, ~15 lines)
&lt;/h2&gt;

&lt;p&gt;I packaged this as an Apify Actor (&lt;a href="https://apify.com/datalume/google-hotels-scraper" rel="noopener noreferrer"&gt;Google Hotels Scraper&lt;/a&gt;) so I don't have to babysit proxies or parsing. You give it your competitive set and a date range:&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;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;YOUR_APIFY_TOKEN&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run_input&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;hotels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;Keio Plaza Hotel Tokyo&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;Hotel Gracery Shinjuku&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;Shinjuku Granbell Hotel&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;Mitsui Garden Hotel Jingugaien Tokyo PREMIER&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;MIMARU TOKYO SHINJUKU WEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;location&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;Tokyo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# helps pick the right hotel for each name
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkIn&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;2026-11-12&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;dateRangeDays&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# up to 60
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adults&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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;USD&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;includeOffers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# every booking site's price, not just Google's headline price
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datalume/google-hotels-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_dataset_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run took &lt;strong&gt;41 seconds&lt;/strong&gt; and returned &lt;strong&gt;35 rows (5 hotels × 7 dates) — no gaps&lt;/strong&gt;. Each row has the hotel, the date, and an &lt;code&gt;offers&lt;/code&gt; list with 19–38 booking sites (Booking.com, Expedia, Agoda, Trip.com, the official site…), so about 1,000 site prices in total.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The rate calendar
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;calendar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pivot_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkIn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowestOfferPricePerNight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheapest price per night (USD, 2 adults, names shortened):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hotel&lt;/th&gt;
&lt;th&gt;Thu 11-12&lt;/th&gt;
&lt;th&gt;Fri 11-13&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Sat 11-14&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;Sun 11-15&lt;/th&gt;
&lt;th&gt;Mon 11-16&lt;/th&gt;
&lt;th&gt;Tue 11-17&lt;/th&gt;
&lt;th&gt;Wed 11-18&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hotel Gracery Shinjuku&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;td&gt;226&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keio Plaza&lt;/td&gt;
&lt;td&gt;269&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;td&gt;270&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;245&lt;/td&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;251&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MIMARU Shinjuku West&lt;/td&gt;
&lt;td&gt;421&lt;/td&gt;
&lt;td&gt;420&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;682&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;453&lt;/td&gt;
&lt;td&gt;333&lt;/td&gt;
&lt;td&gt;336&lt;/td&gt;
&lt;td&gt;333&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mitsui Garden Jingugaien&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;td&gt;249&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;354&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;242&lt;/td&gt;
&lt;td&gt;235&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shinjuku Granbell&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;228&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Saturday spike jumps out immediately: MIMARU +62% vs. Friday, Granbell +57%, Mitsui Garden +42% — while Keio Plaza barely moves. If you were pricing a hotel in this set, that's exactly the signal you'd want before the weekend fills up.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Which booking site is cheapest?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowestOfferProvider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;value_counts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cheapest site&lt;/th&gt;
&lt;th&gt;Hotel-nights&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;klook&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vio.com&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KAYAK.com&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HyperAir.com&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trivago DEALS&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;Not Booking.com, not Expedia: a single OTA had the lowest price for &lt;strong&gt;18 of 35&lt;/strong&gt; hotel-nights. In 4 cases the hotel's own website was the cheapest.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Official site vs. the cheapest OTA (rate parity)
&lt;/h2&gt;

&lt;p&gt;Each offer has an &lt;code&gt;isOfficialSite&lt;/code&gt; flag, so comparing the direct price with the market takes two lines:&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;officialSite&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;offs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pricePerNight&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;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;offs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isOfficialSite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;officialVsCheapestPct&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="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;officialSite&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowestOfferPricePerNight&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Hotel Gracery Shinjuku:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Cheapest site&lt;/th&gt;
&lt;th&gt;Cheapest&lt;/th&gt;
&lt;th&gt;Official site&lt;/th&gt;
&lt;th&gt;Official vs. cheapest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11-12&lt;/td&gt;
&lt;td&gt;klook&lt;/td&gt;
&lt;td&gt;$176&lt;/td&gt;
&lt;td&gt;$225&lt;/td&gt;
&lt;td&gt;+27%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-13&lt;/td&gt;
&lt;td&gt;Vio.com&lt;/td&gt;
&lt;td&gt;$226&lt;/td&gt;
&lt;td&gt;$292&lt;/td&gt;
&lt;td&gt;+29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-14&lt;/td&gt;
&lt;td&gt;klook&lt;/td&gt;
&lt;td&gt;$228&lt;/td&gt;
&lt;td&gt;$281&lt;/td&gt;
&lt;td&gt;+23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-15&lt;/td&gt;
&lt;td&gt;klook&lt;/td&gt;
&lt;td&gt;$191&lt;/td&gt;
&lt;td&gt;$215&lt;/td&gt;
&lt;td&gt;+13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-16&lt;/td&gt;
&lt;td&gt;Trip.com&lt;/td&gt;
&lt;td&gt;$191&lt;/td&gt;
&lt;td&gt;$215&lt;/td&gt;
&lt;td&gt;+13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-17&lt;/td&gt;
&lt;td&gt;klook&lt;/td&gt;
&lt;td&gt;$153&lt;/td&gt;
&lt;td&gt;$215&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+41%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-18&lt;/td&gt;
&lt;td&gt;official site&lt;/td&gt;
&lt;td&gt;$225&lt;/td&gt;
&lt;td&gt;$225&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 6 of 7 nights someone undercut the direct rate, by up to 41%. (The official price was usually within a few dollars of Booking.com — the undercutting came from smaller OTAs.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest caveat:&lt;/strong&gt; sites don't always quote the same room type. In my data one hotel's official site showed $1,045 on a Saturday while the cheapest OTA was $228 — not a parity violation, just a much bigger room. For strict like-for-like checks, turn on &lt;code&gt;includeRooms&lt;/code&gt; and compare the same room name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;The Actor charges per row: $0.004 per hotel-date with booking-site prices ($0.0015 without). This run was 35 rows → &lt;strong&gt;$0.14&lt;/strong&gt;. A month for 5 competitors (150 rows) is about $0.60, and you can schedule it to run every morning in Apify. Hotel-dates that Google doesn't show (e.g. sold out) come back as &lt;code&gt;priceStatus: "notListed"&lt;/code&gt; and aren't charged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Don't build a competitor calendar from a city search — Google re-ranks per date and you'll get holes. Pin the hotels.&lt;/li&gt;
&lt;li&gt;"Cheapest site" is surprisingly concentrated; it's worth knowing which OTA that is in your market.&lt;/li&gt;
&lt;li&gt;Rate parity checks need a room-type sanity check before you send anyone an angry email.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I run datalume, the publisher of the &lt;a href="https://apify.com/datalume/google-hotels-scraper" rel="noopener noreferrer"&gt;Google Hotels Scraper&lt;/a&gt; Actor used here. Both the Actor and this article were built with AI assistance; every number comes from a real run on Oct 29, 2026 (Google Hotels, US) and was checked against the raw data. Questions welcome in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>datascience</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
