<?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: Ben</title>
    <description>The latest articles on DEV Community by Ben (@benthepythondev).</description>
    <link>https://dev.to/benthepythondev</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%2F3960119%2F6b3cea53-7207-4e2d-92b1-1073e28fd866.png</url>
      <title>DEV Community: Ben</title>
      <link>https://dev.to/benthepythondev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benthepythondev"/>
    <language>en</language>
    <item>
      <title>Merge YouTube exports without losing where each value came from</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Fri, 11 Sep 2026 04:17:24 +0000</pubDate>
      <link>https://dev.to/benthepythondev/merge-youtube-exports-without-losing-where-each-value-came-from-50bo</link>
      <guid>https://dev.to/benthepythondev/merge-youtube-exports-without-losing-where-each-value-came-from-50bo</guid>
      <description>&lt;p&gt;One video appeared in two of my September 10 exports. YouTube search returned 7,493,319 views and a duration of 7,341 seconds for Programming with Mosh's &lt;em&gt;Python Full Course for Beginners&lt;/em&gt;. A later metadata lookup returned the same title, but both numbers were &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An ordinary dictionary update would have erased the useful values. Replacing those nulls with zero would have invented a collapse in views.&lt;/p&gt;

&lt;p&gt;I maintain the six Apify Actors used below. This guide combines their existing successful cloud exports into a small local index, retaining the source of every observation. You can reproduce the example offline with Python 3.11 or later; it needs no account, token or installed package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual overlap
&lt;/h2&gt;

&lt;p&gt;Both observations refer to video &lt;code&gt;K5KVEU3aaeQ&lt;/code&gt;. These are dated measurements, not current view counts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation on September 10, 2026, UTC&lt;/th&gt;
&lt;th&gt;View count&lt;/th&gt;
&lt;th&gt;Duration, seconds&lt;/th&gt;
&lt;th&gt;Source detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Search, 17:53:13&lt;/td&gt;
&lt;td&gt;7,493,319&lt;/td&gt;
&lt;td&gt;7,341&lt;/td&gt;
&lt;td&gt;Query: &lt;code&gt;python tutorial&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata, 18:33:17&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;metadata_source: oembed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The metadata Actor's cloud response supplied basic oEmbed information. Detailed metadata had worked locally, but that did not establish cloud access to those fields. Keep the source marker in any downstream export that uses this Actor.&lt;/p&gt;

&lt;p&gt;The index groups observations by YouTube video ID. It deliberately leaves both records intact, so a report can choose the value appropriate to its purpose without rewriting the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the export for the question
&lt;/h2&gt;

&lt;p&gt;These six existing runs produced 15 rows across 14 distinct videos. I read their datasets back and used selected metadata columns for the downloadable sample; it contains no caption text or full descriptions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source and tested example&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Useful distinction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-search-results-scraper" rel="noopener noreferrer"&gt;YouTube Search&lt;/a&gt;: Python tutorial queries&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Retain the query and &lt;code&gt;search_rank&lt;/code&gt;. The cap applies per query, with duplicate IDs and exclusions reducing output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-playlist-extractor" rel="noopener noreferrer"&gt;Playlist Extractor&lt;/a&gt;: Corey Schafer's Python playlist&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Keep &lt;code&gt;playlist_id&lt;/code&gt; and &lt;code&gt;playlist_index&lt;/code&gt;; playlist position is different from search rank.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-channel-intelligence" rel="noopener noreferrer"&gt;Channel RSS&lt;/a&gt;: recent Linus Tech Tips uploads&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Use the source publication date for a recent-upload feed. RSS does not provide a complete channel archive.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-shorts-scraper" rel="noopener noreferrer"&gt;Shorts Scraper&lt;/a&gt;: Apify's Shorts tab&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;This export calls its identifier &lt;code&gt;short_id&lt;/code&gt;. The index maps that identifier to the same video-ID namespace while preserving the original fields.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-video-metadata-scraper" rel="noopener noreferrer"&gt;Video Metadata&lt;/a&gt;: one known Mosh video&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The cloud response used oEmbed and left detailed metrics null. It cannot fill every gap in discovery output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://apify.com/benthepythondev/youtube-transcript-scraper" rel="noopener noreferrer"&gt;Transcript Scraper&lt;/a&gt;: one known video with English captions&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Caption availability, language and generation status describe a track. Translated-track delivery remains unverified.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The search sample ran on 1.0.3 and the caption sample on 1.0.12; their later 1.0.4 and 1.0.13 releases changed schema declarations without changing those runtimes. The other sample builds are Playlist/Shorts 1.0.4, Channel RSS 1.0.9 and Video Metadata 1.0.6. These independent examples illustrate the record contracts; they are not a single chained discovery-to-caption run.&lt;/p&gt;

&lt;p&gt;The six recorded runs used about $0.00467 of platform resources, excluding builds. That is an owner-test resource measurement, not the amount a customer would pay. Each Actor's Pricing tab governs customer fees. No new cloud run is needed to reproduce the local example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the local index
&lt;/h2&gt;

&lt;p&gt;Download these three files from the &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;existing workflow Gist&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;youtube_observation_index.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_youtube_observation_index.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;youtube-observations-sample.json&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep them in one directory, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python test_youtube_observation_index.py
python youtube_observation_index.py youtube-observations-sample.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; first-index.json
python youtube_observation_index.py youtube-observations-sample.json youtube-observations-sample.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; repeat-index.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check exercises the real search/oEmbed overlap, missing values, Shorts IDs and repeat imports. It also checks deliberately invalid inputs and a synthetic successful empty run; those checks are separate from the cloud examples.&lt;/p&gt;

&lt;p&gt;Confirm the repeat output and inspect the overlap:&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;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first-index.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repeat-index.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first-index.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;assert&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;index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;runs&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;6&lt;/span&gt;
&lt;span class="k"&gt;assert&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;index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;videos&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;14&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;sum&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;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observations&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;videos&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;15&lt;/span&gt;

&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&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;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;videos&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;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&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;K5KVEU3aaeQ&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;observation&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observations&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;"&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;view_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output includes &lt;code&gt;7493319&lt;/code&gt; for search and &lt;code&gt;None&lt;/code&gt; for metadata. It does not choose a single authoritative view count for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Import your own successful exports
&lt;/h2&gt;

&lt;p&gt;Download a run's dataset as JSON. Wrap it in a list containing an object with these fields: &lt;code&gt;source&lt;/code&gt; (one of the six Actor names above), &lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;observed_at&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;items&lt;/code&gt;. Set &lt;code&gt;items&lt;/code&gt; to the downloaded array. Use the run's actual ID and its timezone-bearing finish time; &lt;code&gt;observed_at&lt;/code&gt; is the collection checkpoint, not the video's publication date.&lt;/p&gt;

&lt;p&gt;Only &lt;code&gt;SUCCEEDED&lt;/code&gt; exports enter this index. Inspect failures and partial datasets separately before deciding whether they are usable. A successful export with zero rows remains in the &lt;code&gt;runs&lt;/code&gt; list, because silence from a bounded search does not prove that videos were removed.&lt;/p&gt;

&lt;p&gt;Supply all the export files you want to combine as arguments. The script builds a fresh index from those files; it does not read an existing index, start Actors, send alerts or maintain a scheduled monitor. Keep the source files as your archive and write each comparison to a new output filename. Shell redirection can truncate an existing file before Python validates the inputs.&lt;/p&gt;

&lt;p&gt;The same source/run ID with identical contents is harmless to import twice. Conflicting contents under that identity cause an error, which catches accidentally relabelled or incomplete downloads. Rows within one export remain separate observations, including repeated appearances of a video. Their &lt;code&gt;export_row&lt;/code&gt; is simply the one-based row number in that downloaded file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding which value to display
&lt;/h2&gt;

&lt;p&gt;For a research reading list, a title and canonical video link may be enough. A trend chart needs compatible measurements over time. Mixing an old search view count with a new metadata timestamp would make a number look fresher than it is, even if you correctly ignored the null.&lt;/p&gt;

&lt;p&gt;Keep that decision in the report: select an observation whose field is present, show its collection time and identify its source. Playlist position belongs to a particular playlist; search rank belongs to a particular query. Neither is a permanent property of the video.&lt;/p&gt;

&lt;p&gt;The downloadable script retains all fields you supply, so choose what you store. For captions, this sample keeps availability and language metadata without redistributing the text. Use content you have permission to process, and respect source restrictions. The in-memory index suits small research exports; a larger archive needs durable storage and an explicit retention policy.&lt;/p&gt;

</description>
      <category>python</category>
      <category>dataengineering</category>
      <category>webscraping</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Track Airbnb stay prices without mistaking missing results for bookings</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Thu, 10 Sep 2026 22:47:44 +0000</pubDate>
      <link>https://dev.to/benthepythondev/track-airbnb-stay-prices-without-mistaking-missing-results-for-bookings-3inb</link>
      <guid>https://dev.to/benthepythondev/track-airbnb-stay-prices-without-mistaking-missing-results-for-bookings-3inb</guid>
      <description>&lt;p&gt;A room can disappear from a short Airbnb search export even when nothing about its availability has changed. Search order changes, and a three-result limit only captures three observations. Treating every missing room as booked would turn that sampling limit into a false alert.&lt;/p&gt;

&lt;p&gt;This Python and SQLite workflow keeps price observations for one fixed stay. It compares a room only with its earlier observation for the same destination, dates, guest counts and currency. Missing rooms keep their history. A missing numeric price stays null.&lt;/p&gt;

&lt;p&gt;I maintain the &lt;a href="https://apify.com/benthepythondev/airbnb-search-scraper" rel="noopener noreferrer"&gt;Airbnb Search Scraper&lt;/a&gt; used here. The &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;code, offline check, input and dated sample&lt;/a&gt; are available together. The script needs Python 3.11 or newer and uses the standard library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two real captures showed
&lt;/h2&gt;

&lt;p&gt;On September 10, 2026, I captured three public search results for &lt;strong&gt;Berlin, September 18–20, two adults, EUR&lt;/strong&gt;, then repeated that exact search about five minutes later. Both runs used the existing Actor build 1.0.6. Each returned three records and stopped at the result limit.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Room ID&lt;/th&gt;
&lt;th&gt;First observed total&lt;/th&gt;
&lt;th&gt;Second observed total&lt;/th&gt;
&lt;th&gt;Interpretation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;721527807549293451&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;€239&lt;/td&gt;
&lt;td&gt;€239&lt;/td&gt;
&lt;td&gt;Same observed price&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;19976281&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;€257&lt;/td&gt;
&lt;td&gt;€257&lt;/td&gt;
&lt;td&gt;Same observed price&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;26180749&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;€240&lt;/td&gt;
&lt;td&gt;Not in this export&lt;/td&gt;
&lt;td&gt;Keep the earlier observation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;23848408&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not in the first export&lt;/td&gt;
&lt;td&gt;€286&lt;/td&gt;
&lt;td&gt;First observation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The database contains six observations across four room IDs. No price change was observed in this small live example. The offline check separately exercises a price decrease, missing prices, invalid stay data and failed imports; those checks are not additional market observations. The download &lt;code&gt;airbnb-stay-sample.jsonl&lt;/code&gt; contains the six dated observations and their comparison fields.&lt;/p&gt;

&lt;p&gt;The source labels these prices &lt;strong&gt;“for 2 nights.”&lt;/strong&gt; They are observed search totals, not nightly prices or confirmed checkout quotes. Fees, availability and the final payable amount can change. This sample is dated evidence, not a current booking recommendation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the stay fixed
&lt;/h2&gt;

&lt;p&gt;Download &lt;code&gt;airbnb_stay_watch.py&lt;/code&gt;, &lt;code&gt;test_airbnb_stay_watch.py&lt;/code&gt; and &lt;code&gt;airbnb-stay-input.json&lt;/code&gt; from the Gist into the same directory. The input used above is:&lt;br&gt;
&lt;/p&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;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Berlin, Germany"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkIn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkOut"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"adults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"infants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPages"&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="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;Choose future dates before your first capture. Use a new output folder when changing any input, including the page or result limit. Rolling dates such as “seven days from now” make successive prices describe different stays, so this workflow requires explicit dates.&lt;/p&gt;

&lt;p&gt;Run the check before using your token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python test_airbnb_stay_watch.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs offline and starts no Actor. Set &lt;code&gt;APIFY_TOKEN&lt;/code&gt; through your environment or secret manager; keep it out of the input file and downloaded code. Then capture your chosen stay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python airbnb_stay_watch.py capture berlin-stay &lt;span class="nt"&gt;--input&lt;/span&gt; airbnb-stay-input.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the input file outside &lt;code&gt;berlin-stay&lt;/code&gt;. The folder contains &lt;code&gt;stays.sqlite3&lt;/code&gt;, &lt;code&gt;stay-history.jsonl&lt;/code&gt; and completed run receipts. Back it up if the history matters. Run commands sequentially against one folder.&lt;/p&gt;

&lt;p&gt;Later, repeat the same capture command. That starts a new paid Actor run for the same search and adds observations for its returned rooms. The second real capture printed:&lt;br&gt;
&lt;/p&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;"new_observations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"comparisons"&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="nl"&gt;"same_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"first_observation"&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;"source_stop_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"result_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stored_observations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stored_runs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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;h2&gt;
  
  
  Read the history without inventing changes
&lt;/h2&gt;

&lt;p&gt;Each JSONL line contains the source listing, run ID, comparison status, previous total and price delta. The statuses are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;first_observation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This room has no previous observation in this folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;same_price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Both numeric totals are present and equal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;price_changed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Both totals are present and differ; &lt;code&gt;price_delta&lt;/code&gt; is current minus previous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unpriced&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;At least one of the two observations lacks a numeric total; no numeric delta is inferred&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The importer checks the room ID and URL, destination, dates, all guest counts, currency and source price labels before writing. It uses decimal arithmetic for price differences and rejects duplicate room IDs within one run. An invalid row rejects the entire import, preserving the earlier database history.&lt;/p&gt;

&lt;p&gt;A missing room creates no replacement record. Its earlier price remains a historical observation, not a claim that it is still available. Even an unchanged numeric price says nothing about cancellation terms, minimum-stay rules or checkout availability.&lt;/p&gt;

&lt;p&gt;Regenerate the JSONL file from SQLite without making an API request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python airbnb_stay_watch.py &lt;span class="nb"&gt;export &lt;/span&gt;berlin-stay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Recover a completed run before starting another
&lt;/h2&gt;

&lt;p&gt;In the first live capture, the finished run's dataset export was not immediately consistent with its summary. The importer rejected the incomplete read and kept the pending run receipt. Once the export was available, I resumed &lt;strong&gt;that same run&lt;/strong&gt;, without starting another.&lt;/p&gt;

&lt;p&gt;The script now retries dataset reads up to three times, two seconds apart. It never automatically repeats a start request. If the export still is not ready, use the run ID printed by the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python airbnb_stay_watch.py capture berlin-stay &lt;span class="nt"&gt;--input&lt;/span&gt; airbnb-stay-input.json &lt;span class="nt"&gt;--run-id&lt;/span&gt; YOUR_EXISTING_RUN_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This path only reads the existing run. It validates the saved input before importing anything. Reimporting an already imported run adds zero observations; the real second run was resumed this way and its JSONL export remained byte-identical.&lt;/p&gt;

&lt;p&gt;If a start request times out before a run ID is received, &lt;code&gt;pending.json&lt;/code&gt; prevents another automatic start. Inspect your &lt;a href="https://console.apify.com/actors/runs" rel="noopener noreferrer"&gt;Apify runs&lt;/a&gt; for that request's time and exact input, then resume the matching run ID. Do not delete the pending receipt and blindly repeat the start. Failed runs leave the history intact and need inspection before a new capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and scope
&lt;/h2&gt;

&lt;p&gt;At the September 10 Free-tier price, three exported records cost &lt;strong&gt;$0.015&lt;/strong&gt;, plus &lt;strong&gt;$0.00005&lt;/strong&gt; for a 512 MB start: &lt;strong&gt;$0.01505 per capture&lt;/strong&gt;. The two demonstrated captures have a calculated Free-tier Actor-price equivalent of &lt;strong&gt;$0.03010&lt;/strong&gt;. Repeat observations are still billable results; SQLite prevents duplicate imports, not charges for fresh source runs. Check the Actor's &lt;a href="https://apify.com/benthepythondev/airbnb-search-scraper/pricing" rel="noopener noreferrer"&gt;Pricing tab&lt;/a&gt; for your current tier.&lt;/p&gt;

&lt;p&gt;Every start made by this example sets a &lt;strong&gt;$0.06 maximum Actor charge&lt;/strong&gt;, 512 MB and a 300-second timeout. The wrapper accepts at most ten results and two source pages. A spending or result limit may leave the search incomplete. The cap applies to Actor charges; separate platform or local storage costs are outside it. These demonstrations were owner tests, not customer sales.&lt;/p&gt;

&lt;p&gt;This workflow does not book rooms, send notifications or create a recurring schedule. For long-term rental change alerts, the &lt;a href="https://dev.to/benthepythondev/build-an-n8n-rental-alert-that-skips-unchanged-listings-4n4f"&gt;Zumper workflow&lt;/a&gt; covers a different source and monitoring contract. Use the fixed-stay workflow here when dates and occupancy are part of the price you need to compare.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>webscraping</category>
      <category>automation</category>
    </item>
    <item>
      <title>Join LinkedIn jobs to their company pages with Python</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Thu, 10 Sep 2026 17:07:55 +0000</pubDate>
      <link>https://dev.to/benthepythondev/join-linkedin-jobs-to-their-company-pages-with-python-18b</link>
      <guid>https://dev.to/benthepythondev/join-linkedin-jobs-to-their-company-pages-with-python-18b</guid>
      <description>&lt;p&gt;LinkedIn job records include an employer URL. You can use that URL to attach a company's public industry, website and size range to a job feed, keeping the source identity through both steps. This is useful when the employer's displayed name is abbreviated, shared by several organizations or belongs to a recruiting agency.&lt;/p&gt;

&lt;p&gt;I maintain the &lt;a href="https://apify.com/benthepythondev/linkedin-jobs-scraper" rel="noopener noreferrer"&gt;LinkedIn Jobs&lt;/a&gt; and &lt;a href="https://apify.com/benthepythondev/linkedin-company-scraper" rel="noopener noreferrer"&gt;LinkedIn Company&lt;/a&gt; Actors used here. This example joins their exports with Python and SQLite. It saves the jobs before attempting company lookups, so a company failure leaves a recoverable job export.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the live sample produced
&lt;/h2&gt;

&lt;p&gt;On September 10, 2026, a bounded search for Python jobs in Berlin returned three postings. Their employer URLs led to three verified company records:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Posted employer&lt;/th&gt;
&lt;th&gt;Company ID&lt;/th&gt;
&lt;th&gt;Public company-size range&lt;/th&gt;
&lt;th&gt;LinkedIn-associated members&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flix&lt;/td&gt;
&lt;td&gt;2726149&lt;/td&gt;
&lt;td&gt;1,001–5,000 employees&lt;/td&gt;
&lt;td&gt;2,631&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nelly Solutions&lt;/td&gt;
&lt;td&gt;74522753&lt;/td&gt;
&lt;td&gt;51–200 employees&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Berliner Verlag&lt;/td&gt;
&lt;td&gt;1178870&lt;/td&gt;
&lt;td&gt;201–500 employees&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are dated source observations. Nelly's size range and associated-member count disagree because they are different LinkedIn fields; the workflow keeps both. Neither establishes an independently verified workforce size. The employer on a job advertisement can also be an agency, so this join doesn't identify an undisclosed end client.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuyjj74v19zfy7o8l0wmn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuyjj74v19zfy7o8l0wmn.png" alt="Two captures of three LinkedIn jobs: three initial company lookups, no repeated lookups, and no duplicate jobs." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first capture stored three jobs, including their complete public descriptions. Enrichment attached three company profiles. A fresh job run returned the same IDs; the database added zero jobs and the enrichment step made zero API calls. The resulting JSONL file matched byte for byte.&lt;/p&gt;

&lt;p&gt;The Jobs run IDs were &lt;code&gt;vM96cCZQDC8v24ltV&lt;/code&gt; and &lt;code&gt;4AyEwm8YDWjMvrQ2v&lt;/code&gt;; the Company run was &lt;code&gt;uNaVz3GdaNyn4gXUP&lt;/code&gt;. All three used build &lt;code&gt;1.0.4&lt;/code&gt; of their respective Actor and ended successfully. This sample covers three returned postings, not every Python vacancy in Berlin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the two steps
&lt;/h2&gt;

&lt;p&gt;Download &lt;code&gt;linkedin_hiring_feed.py&lt;/code&gt;, &lt;code&gt;test_linkedin_hiring_feed.py&lt;/code&gt; and &lt;code&gt;linkedin-hiring-input.json&lt;/code&gt; from the &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;public workflow files&lt;/a&gt;. Keep the Python files together. They require Python 3.11 or newer and use its standard library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.11 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
python test_linkedin_hiring_feed.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checks run without credentials or network access. They exercise repeated imports, exact employer matching, invalid rows, a failed company run and recovery from the saved jobs. Set &lt;code&gt;APIFY_TOKEN&lt;/code&gt; through your environment or secret manager before the live commands; keep it out of the input JSON and downloaded scripts.&lt;/p&gt;

&lt;p&gt;The supplied search is small:&lt;br&gt;
&lt;/p&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;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Berlin, Germany"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeRange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"week"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sortBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"strictKeywords"&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;"onlyNew"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proxyConfiguration"&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="nl"&gt;"useApifyProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Capture the jobs, then enrich the pending employer URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python linkedin_hiring_feed.py capture linkedin-hiring-input.json berlin-hiring
python linkedin_hiring_feed.py enrich berlin-hiring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each command that starts an Actor fixes memory at 512 MB, timeout at 300 seconds and the &lt;strong&gt;maximum Actor charge at $0.05&lt;/strong&gt;. The two starts have separate caps. The example accepts at most ten jobs and five search pages per capture; each enrichment command requests at most ten pending companies. Inspect the printed counts before increasing your search within those bounds.&lt;/p&gt;

&lt;p&gt;The folder contains &lt;code&gt;hiring.sqlite3&lt;/code&gt;, the persistent database; &lt;code&gt;hiring-feed.jsonl&lt;/code&gt;, the joined export; and &lt;code&gt;runs/&lt;/code&gt;, the run receipts and successfully downloaded source rows. Keep the input file outside this folder. A changed input requires a different folder, which prevents accidentally mixing two search definitions.&lt;/p&gt;

&lt;p&gt;Run the same two commands sequentially on later days. &lt;code&gt;capture&lt;/code&gt; stores each job ID once. &lt;code&gt;enrich&lt;/code&gt; requests only eligible employer URLs without a saved company snapshot, so several jobs from the same employer share one lookup. The second command exits without contacting Apify when nothing is pending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the joined record
&lt;/h2&gt;

&lt;p&gt;Every JSONL line has an outer &lt;code&gt;job_id&lt;/code&gt;, the original &lt;code&gt;job&lt;/code&gt;, a &lt;code&gt;company_profile&lt;/code&gt; and an &lt;code&gt;enrichment_status&lt;/code&gt;. These selected fields come from the saved Nelly record; the full file also retains the job description and the remaining company fields:&lt;br&gt;
&lt;/p&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;"job_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4434332752"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"job"&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;"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;"Senior Software Engineer, Python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nelly Solutions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"company_url"&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://de.linkedin.com/company/nelly-solutions"&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;"company_profile"&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;"company_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"74522753"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://www.linkedin.com/company/nelly-solutions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"industry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Software Development"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"company_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"51-200 employees"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"linkedin_employee_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;206&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identity_verified"&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="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;"enrichment_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verified"&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;The join normalizes country-specific LinkedIn hostnames, case, trailing slashes and tracking parameters. It then requires the Company Actor's verified requested URL to equal that normalized employer URL. Company names never serve as a fallback key. Related organizations or a profile returned for a different URL fail validation.&lt;/p&gt;

&lt;p&gt;Missing or unsupported employer links keep their jobs in the export with &lt;code&gt;missing_company_url&lt;/code&gt; or &lt;code&gt;unsupported_company_url&lt;/code&gt;. A supported URL awaiting a successful lookup has &lt;code&gt;pending&lt;/code&gt;. Only a matched, verified company row receives &lt;code&gt;verified&lt;/code&gt;; the other states have a null &lt;code&gt;company_profile&lt;/code&gt;. The Company Actor currently accepts named company slugs containing letters, digits, underscores and hyphens. Numeric redirect URLs and other forms need separate source review; this example doesn't rewrite them into guessed company pages.&lt;/p&gt;

&lt;p&gt;Use the outer &lt;code&gt;job_id&lt;/code&gt; as the destination key when importing into a sheet or database. Retain &lt;code&gt;job.scraped_at&lt;/code&gt; and &lt;code&gt;company_profile.scraped_at&lt;/code&gt; so readers can see when each observation was collected. The saved profile also has the source-published website, when available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recover without collecting the jobs again
&lt;/h2&gt;

&lt;p&gt;The first live capture stopped because a separate dataset-statistics read disagreed with the item response. A follow-up read confirmed all three jobs. The released script validates the total from the same paginated response as the items, removing that separate statistics dependency. I resumed the existing successful run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python linkedin_hiring_feed.py capture linkedin-hiring-input.json berlin-hiring &lt;span class="nt"&gt;--run-id&lt;/span&gt; vM96cCZQDC8v24ltV
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For your own recovery, substitute your own run ID. The script checks the Actor identity, successful terminal status, input and complete bounded export before importing it. A failed run cannot pass as a successful empty feed. If a start request times out before returning an ID, inspect your Apify Runs page before starting another; the server may already have accepted it. The script never retries a start automatically.&lt;/p&gt;

&lt;p&gt;When a company batch fails, the captured jobs remain saved and their supported employer URLs remain pending. Inspect that run and the source issue before retrying &lt;code&gt;enrich&lt;/code&gt;; no new Jobs run is needed. A successful Company run whose input still matches the pending batch can also be imported with &lt;code&gt;enrich berlin-hiring --run-id YOUR_COMPANY_RUN_ID&lt;/code&gt;. Failed batches are not partially imported. A successful spending-limited batch can leave companies pending, and the printed count makes that visible.&lt;/p&gt;

&lt;p&gt;SQLite rolls back an import if a later row has a duplicate or mismatched identity. The JSONL file replaces its predecessor only after writing finishes. If export is interrupted, regenerate it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python linkedin_hiring_feed.py &lt;span class="nb"&gt;export &lt;/span&gt;berlin-hiring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command makes no network requests. Avoid overlapping commands on one folder, and retain the database as the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Costs and observation limits
&lt;/h2&gt;

&lt;p&gt;At the verified Free-tier rates, Jobs costs $0.002 per exported posting and Company costs $0.004 per exported company. Each run at this memory setting adds a $0.00005 start event.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Calculated Actor charge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial three-job capture&lt;/td&gt;
&lt;td&gt;$0.00605&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three company profiles&lt;/td&gt;
&lt;td&gt;$0.01205&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh three-job repeat&lt;/td&gt;
&lt;td&gt;$0.00605&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeat enrichment with no pending companies&lt;/td&gt;
&lt;td&gt;$0.00000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total for the demonstrated sequence&lt;/td&gt;
&lt;td&gt;$0.02415&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are customer-price equivalents calculated from the recorded events, not revenue from my owner tests. Apify's current pricing panel is authoritative, and plan discounts may apply. Repeated job captures still export and charge for the snapshot; local ID deduplication prevents duplicate destination records. An existing-run import makes API reads but starts no new Actor run.&lt;/p&gt;

&lt;p&gt;This example keeps the &lt;strong&gt;first observation&lt;/strong&gt; of each job and the &lt;strong&gt;first successful company snapshot&lt;/strong&gt; per URL. Later captures won't update their descriptions or company metrics. It doesn't detect job closure, refresh company headcount, send alerts or activate a schedule. The dates inside the saved records remain meaningful precisely because they're preserved. For a fresh research snapshot, use a new folder; that starts a separate history and incurs fresh lookups.&lt;/p&gt;

&lt;p&gt;The source search and text filtering bound which jobs enter this feed. An absent result does not establish that a vacancy closed, and a public profile may become unavailable. Exports load the local feed into memory; use batched or streaming exports if the accumulated archive becomes large. The workflow contains no personal-profile collection or employee enumeration. Follow source terms and your organization's data-use requirements.&lt;/p&gt;

&lt;p&gt;You can inspect the &lt;a href="https://apify.com/benthepythondev/linkedin-jobs-scraper/examples/berlin-python-jobs-full-descriptions" rel="noopener noreferrer"&gt;Berlin Jobs example&lt;/a&gt; and the separate &lt;a href="https://apify.com/benthepythondev/linkedin-company-scraper/examples/verified-microsoft-github-company-profiles" rel="noopener noreferrer"&gt;verified Company example&lt;/a&gt; before running the workflow in your own account. If you need an account, &lt;a href="https://apify.com/?fpr=qolupv" rel="noopener noreferrer"&gt;this Apify signup link&lt;/a&gt; is a referral link; I may earn a commission at no extra cost to you.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Refresh website Markdown without losing unchanged pages</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:33:51 +0000</pubDate>
      <link>https://dev.to/benthepythondev/refresh-website-markdown-without-losing-unchanged-pages-1gba</link>
      <guid>https://dev.to/benthepythondev/refresh-website-markdown-without-losing-unchanged-pages-1gba</guid>
      <description>&lt;p&gt;A documentation refresh can return a page with &lt;code&gt;markdown: null&lt;/code&gt; because its content hasn't changed. If an importer treats that row as a replacement document, it erases the useful text it collected earlier. The fix is to keep the previous content and compare hashes before updating it.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/benthepythondev/website-markdown-crawler" rel="noopener noreferrer"&gt;Website Markdown Crawler on Apify&lt;/a&gt;. This walkthrough turns its exports into a local documentation corpus using Python's standard library and SQLite. It includes a runner that supplies the previous hashes automatically, so you can repeat a crawl without copying state between files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What two real refreshes returned
&lt;/h2&gt;

&lt;p&gt;On September 10, 2026, I ran the same five-page Python tutorial crawl twice on build &lt;code&gt;1.0.2&lt;/code&gt;. The first run imported the content; the second supplied the hashes from that local database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos1ivn57t2bih27nldiz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos1ivn57t2bih27nldiz.png" alt="Measured five-page refresh: five pages retained, zero repeat content writes, and the same calculated Actor charge on both runs." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;First refresh&lt;/th&gt;
&lt;th&gt;Repeat refresh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exported page records&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Markdown body bytes, UTF-8&lt;/td&gt;
&lt;td&gt;90,019&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content versions written locally&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complete pages retained locally&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calculated Free-tier Actor charge&lt;/td&gt;
&lt;td&gt;$0.00505&lt;/td&gt;
&lt;td&gt;$0.00505&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The local JSONL corpus and hash file matched byte for byte after the repeat. Zero Markdown bytes describes those five response fields; the repeat still transfers metadata and still fetches the source pages. Both runs charge for five exported records. The benefit is preserving the corpus and letting a downstream index skip unchanged content, not eliminating the crawl fee.&lt;/p&gt;

&lt;p&gt;The run IDs are &lt;code&gt;Mr1xmbQyMpwugttds&lt;/code&gt; and &lt;code&gt;6O33lSVE0q6ve33uS&lt;/code&gt;. Both stopped at the requested page limit with 12 discovered URLs pending. Five successful pages do not mean the entire Python tutorial was collected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the nulls come from
&lt;/h2&gt;

&lt;p&gt;The crawler hashes the extracted Markdown. On the next run, the runner builds &lt;code&gt;previousHashes&lt;/code&gt; from SQLite, keyed by the final source URL. A matching hash produces a record like this excerpt from the repeat:&lt;br&gt;
&lt;/p&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;"url"&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://docs.python.org/3/tutorial/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"change_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unchanged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b216133cb14e3dfcc3d354ef3a83bd77b1d1100d955064e1477e2eac4be3928e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"markdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;The importer checks that it already holds content with that exact URL and hash. If it does, it keeps the stored document. If it doesn't, it rejects the import; a hash alone cannot reconstruct a page. New and changed rows must contain text and Markdown whose SHA-256 matches the supplied hash.&lt;/p&gt;

&lt;p&gt;Each stored document keeps &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;markdown&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;content_hash&lt;/code&gt; from its last imported content version. SQLite's URL primary key prevents duplicate documents. A malformed row rolls back that import, including valid rows encountered earlier in the same array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the five-page workflow
&lt;/h2&gt;

&lt;p&gt;Download &lt;code&gt;website_markdown_archive.py&lt;/code&gt;, &lt;code&gt;website_markdown_refresh.py&lt;/code&gt; and &lt;code&gt;website-markdown-input.json&lt;/code&gt; from the &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;public workflow files&lt;/a&gt;. Keep the two Python files together. They need Python 3.11 or newer and no additional packages.&lt;/p&gt;

&lt;p&gt;The supplied input is also available as a &lt;a href="https://apify.com/benthepythondev/website-markdown-crawler/examples/python-tutorial-markdown-pages" rel="noopener noreferrer"&gt;public Apify example Task&lt;/a&gt;:&lt;br&gt;
&lt;/p&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;"startUrls"&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="s2"&gt;"https://docs.python.org/3/tutorial/"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxDepth"&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="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;Create a local environment and run the checks, which need no credentials or network access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.11 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
python website_markdown_archive.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
python website_markdown_refresh.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;APIFY_TOKEN&lt;/code&gt; through your shell environment or secret manager, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python website_markdown_refresh.py website-markdown-input.json docs-archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner starts one Actor run at 512 MB with a 300-second timeout and a &lt;strong&gt;$0.02 maximum Actor charge&lt;/strong&gt;. It prints the run ID, waits for a successful terminal status, checks the coverage/export counts, and imports the result. It doesn't retry the start request automatically.&lt;/p&gt;

&lt;p&gt;The archive folder contains &lt;code&gt;docs.sqlite3&lt;/code&gt;, the persistent source of truth; &lt;code&gt;corpus.jsonl&lt;/code&gt;, one complete stored document per line; and &lt;code&gt;hashes.json&lt;/code&gt;, the current URL-to-hash map. Keep this folder between refreshes. The runner reads its baseline directly from SQLite, so the exported hash file isn't a separate state you need to maintain.&lt;/p&gt;

&lt;p&gt;Repeat the same command after the first run finishes. These are the result fields from my two runs, with file paths and run IDs omitted:&lt;br&gt;
&lt;/p&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="nl"&gt;"received"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content_written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"retained"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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="nl"&gt;"received"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content_written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"retained"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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;Your results can differ when the documentation changes. The importer replaces changed content at the same URL; it stores the latest imported version rather than an edit history. Import in capture order and never overlap refreshes using one archive folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what the crawl should cover
&lt;/h2&gt;

&lt;p&gt;The start URL defines an origin and path subtree. The example follows links under &lt;code&gt;https://docs.python.org/3/tutorial/&lt;/code&gt;; it doesn't wander into every Python documentation section. &lt;code&gt;maxDepth: 1&lt;/code&gt; permits links one step away from the seed, and &lt;code&gt;maxPages: 5&lt;/code&gt; caps attempted pages. Inspect the run's &lt;code&gt;COVERAGE&lt;/code&gt; output before expanding the input.&lt;/p&gt;

&lt;p&gt;A page missing from a bounded crawl is not proof of deletion. This importer never deletes stored documents just because they weren't returned. Handle verified removals separately. Keep the same scope and content selectors between comparisons, or use a separate archive folder when you change what you're collecting.&lt;/p&gt;

&lt;p&gt;Choose the discovery method that matches the material you have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Starting point&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it supplies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A documentation index whose links you want to follow&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/benthepythondev/website-markdown-crawler" rel="noopener noreferrer"&gt;Website Markdown Crawler&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Scoped linked-page Markdown, text, hashes and coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An existing list of page URLs, needing readable text&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/benthepythondev/webpage-text-extractor" rel="noopener noreferrer"&gt;Webpage Text Extractor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Text and metadata for supplied URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An XML sitemap or sitemap index&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/benthepythondev/sitemap-url-extractor" rel="noopener noreferrer"&gt;Sitemap URL Extractor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Declared page URLs and their source sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the first tool produces the hash/null-content contract this importer expects. The others solve adjacent discovery or text-export tasks; don't feed their rows straight into this importer. A sitemap entry is a publisher's declaration, not proof that the URL currently works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep failures separate from updates
&lt;/h2&gt;

&lt;p&gt;A failed Actor run can contain partial dataset output. The runner rejects that run before importing anything. It also rejects a mismatch between the coverage count and downloaded rows. Offline checks cover these cases, a timed-out start request and preservation of the existing database.&lt;/p&gt;

&lt;p&gt;If a start request times out, inspect Apify's Runs page before launching another; the server may already have started it. If you lose the local content, start a new archive folder to request full pages again. Preserve your old folder while checking that recovery.&lt;/p&gt;

&lt;p&gt;The JSONL and hash files replace their predecessors only after each new file finishes writing. SQLite remains authoritative if an export is interrupted. Both imports and exports load data into memory, so this is a small-corpus example; larger collections need batched processing.&lt;/p&gt;

&lt;p&gt;You can feed the complete JSONL documents into your own chunking or search pipeline. Carry the source URL and content hash with each chunk, and compare hashes before rebuilding an embedding. No vector database or retrieval benchmark is part of this example. Treat retrieved page text as source material, never as instructions to an automated tool.&lt;/p&gt;

&lt;p&gt;The crawler reads public server-rendered HTML and respects robots.txt. It doesn't execute JavaScript or use login cookies. Follow source terms and content licenses; Python's documentation has its own &lt;a href="https://docs.python.org/3/license.html" rel="noopener noreferrer"&gt;license and attribution requirements&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At current Free-tier rates, each five-record run costs &lt;code&gt;5 × $0.001 + $0.00005 = $0.00505&lt;/code&gt;; two runs cost $0.01010. That is a calculated customer-price equivalent, not an owner-test payment. Plan discounts may apply, and Apify's Pricing tab is authoritative. Raising memory can change the start charge; the runner fixes it at 512 MB.&lt;/p&gt;

&lt;p&gt;Start with the &lt;a href="https://apify.com/benthepythondev/website-markdown-crawler/examples/python-tutorial-markdown-pages" rel="noopener noreferrer"&gt;five-page Task&lt;/a&gt;, inspect the content, then try the refresh runner on a public documentation section relevant to your project. If you need an account, &lt;a href="https://apify.com/?fpr=qolupv" rel="noopener noreferrer"&gt;this Apify signup link&lt;/a&gt; is a referral link; I may earn a commission at no extra cost to you.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
    </item>
    <item>
      <title>Build an n8n rental alert that skips unchanged listings</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:54:45 +0000</pubDate>
      <link>https://dev.to/benthepythondev/build-an-n8n-rental-alert-that-skips-unchanged-listings-4n4f</link>
      <guid>https://dev.to/benthepythondev/build-an-n8n-rental-alert-that-skips-unchanged-listings-4n4f</guid>
      <description>&lt;p&gt;A daily rental search should not send yesterday's listings again. The useful output is a small set of newly observed listings and asking-rent changes, with enough context to see what changed.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/benthepythondev/zumper-rental-scraper" rel="noopener noreferrer"&gt;Zumper Rental Scraper&lt;/a&gt; on Apify. This walkthrough uses its saved-search state and an n8n email template. The original source runs and sample are from September 9, 2026. On September 10, a fresh three-listing report reached the test inbox through Apify's native email Actor without SMTP credentials. The samples are dated rental data, not a current availability quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the sample walkthrough
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://api.apify.com/v2/key-value-stores/JVZVbAGpV28Wr4uqW/records/zumper-rental-alert-walkthrough.mp4" rel="noopener noreferrer"&gt;Watch the 21-second walkthrough&lt;/a&gt;. This silent clip walks through the September 9 sample and n8n setup. It predates the native email step and does not show a new source run or email delivery; use the written instructions below for the current setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Establish the baseline first
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;downloadable workflow&lt;/a&gt; searches Austin apartments with at least one bedroom and a maximum asking-rent filter of $3,000:&lt;br&gt;
&lt;/p&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"austin-tx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"propertyType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apartments-for-rent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minBeds"&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;"maxListings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includePhotos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"useApifyProxy"&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;"onlyChanges"&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;"monitorId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"austin-rentals-under-3000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"firstRunMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"seedOnly"&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;&lt;code&gt;seedOnly&lt;/code&gt; stores the initial snapshot without exporting the backlog. Later runs with the same monitor ID return newly observed or changed listings. Use &lt;code&gt;emitAll&lt;/code&gt; if you want the first snapshot in the dataset too.&lt;/p&gt;

&lt;p&gt;Keep one monitor ID for one search and avoid overlapping runs. Changing cities or filters deserves a new ID. Leaving &lt;code&gt;resetState&lt;/code&gt; enabled would repeatedly discard the baseline and defeat the monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the live check returned
&lt;/h2&gt;

&lt;p&gt;For the bounded demonstration, I used &lt;code&gt;maxListings: 3&lt;/code&gt; on build &lt;code&gt;1.1.23&lt;/code&gt;. The initial &lt;code&gt;emitAll&lt;/code&gt; snapshot contained:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Listing&lt;/th&gt;
&lt;th&gt;Minimum asking rent&lt;/th&gt;
&lt;th&gt;Source location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seabrook Square&lt;/td&gt;
&lt;td&gt;$1,206/month&lt;/td&gt;
&lt;td&gt;3515 Manor Rd, Austin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Rail&lt;/td&gt;
&lt;td&gt;$1,300/month&lt;/td&gt;
&lt;td&gt;2921 E 17th St, Austin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Rhone&lt;/td&gt;
&lt;td&gt;$1,749/month&lt;/td&gt;
&lt;td&gt;2450 Wickersham Ln, Austin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These can be building-level rent ranges, not individual units available at the minimum price. The Actor does not establish complete Austin market coverage or verify lease terms.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;QDtz5Vvr2bRKoFbQs&lt;/code&gt; exported those three &lt;code&gt;new&lt;/code&gt; rows. The next run, &lt;code&gt;AdjAk7J4AZNMiTJrB&lt;/code&gt;, returned zero duplicates. A separate seed-only monitor returned zero rows on both its initial run and repeat. The &lt;a href="https://benthepythondev00.github.io/austin-rental-monitoring.html" rel="noopener noreferrer"&gt;earlier Austin case study&lt;/a&gt; provides additional background; the downloadable sample records this newer test.&lt;/p&gt;

&lt;p&gt;There was also a failure worth keeping in the record: the first source attempt returned HTTP 403 through a residential route. That run failed before updating the baseline. The next attempt succeeded. A failed source request is not evidence that no rentals changed; check the run status before interpreting an empty dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire the n8n workflow
&lt;/h2&gt;

&lt;p&gt;Import &lt;code&gt;zumper-rental-alerts.json&lt;/code&gt; from the Gist. Its five nodes run a daily schedule, call the Actor, build a text digest, send through Apify and check the mail run's status. The exported workflow is inactive and contains no credentials.&lt;/p&gt;

&lt;p&gt;Create an HTTP Header Auth credential with header name &lt;code&gt;Authorization&lt;/code&gt; and value &lt;code&gt;Bearer YOUR_APIFY_TOKEN&lt;/code&gt;, then select it in both HTTP nodes. In &lt;code&gt;Email rental alert&lt;/code&gt;, replace &lt;code&gt;you@example.com&lt;/code&gt; in the JSON body expression with your Apify account email. No SMTP credential or custom sender address is needed. Check your n8n timezone before enabling the 08:00 schedule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/apify/send-mail" rel="noopener noreferrer"&gt;Apify Send Email&lt;/a&gt; allows Free accounts to send reports to their registered address; paying accounts can send to up to 20 different recipients. It is a reporting tool, not a promotional-mail service. The mail run uses 256 MB and a 60-second timeout, and its platform usage is separate from the rental Actor's charges.&lt;/p&gt;

&lt;p&gt;The request uses 512 MB, a 240-second Actor timeout and a $0.75 maximum run charge. The formatter accepts both an array response and one n8n item per row. It returns no items when the dataset is empty, so the email node receives no digest on an unchanged run. Invalid rows raise an error instead of producing a misleading empty message.&lt;/p&gt;

&lt;p&gt;I exercised the exact formatter code with the real three-row export and checked empty output, invalid rows and repeated inputs. That also caught literal &lt;code&gt;\n&lt;/code&gt; sequences in the older template; the updated formatter produces actual line breaks. The September 10 delivery check used fresh output from build &lt;code&gt;1.1.24&lt;/code&gt;, passed that same formatter's text to Send Email &lt;code&gt;0.3.20&lt;/code&gt;, and verified one received Gmail message with an identical subject and body plus Apify's standard footer.&lt;/p&gt;

&lt;p&gt;I also imported the workflow into n8n &lt;code&gt;2.38.6&lt;/code&gt; and executed two manual checks. A saved copy of the fresh three-row export passed through every downstream node with Send Email in mock mode. A second execution called the live Zumper Actor with the same monitor ID, returned zero changes and stopped before the digest or email nodes. These were isolated test executions; no recurring schedule was activated. The inbox receipt was verified separately from those mock-mail checks.&lt;/p&gt;

&lt;p&gt;The last node throws if the mail Actor fails or has not finished. Inspect the mail run before retrying a delivery: a request timeout can occur after a message was sent. Retry from the saved digest in that n8n execution; restarting the entire rental search can return zero rows because its baseline already advanced.&lt;/p&gt;

&lt;p&gt;You can run the formatter checks without an Apify token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://gist.github.com/e28968a940fe4195ffb5a997fe309503.git apify-workflows
&lt;span class="nb"&gt;cd &lt;/span&gt;apify-workflows
node test_zumper_rental_alerts.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Separate rent cuts from other changes
&lt;/h2&gt;

&lt;p&gt;The current Actor uses &lt;code&gt;change_type&lt;/code&gt;: &lt;code&gt;new&lt;/code&gt;, &lt;code&gt;price_changed&lt;/code&gt; or &lt;code&gt;details_changed&lt;/code&gt;. It does not emit a &lt;code&gt;price_drop&lt;/code&gt; event. To keep only reductions in the minimum asking rent, filter change rows with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;change_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_change_amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The formatter's controlled test uses a simulated $2,000 → $1,800 change to check the “was” label. That is a formatting test, not an observed market price cut. A change in the maximum rent alone can have a zero minimum-rent delta. Missing listings are not labeled rented or withdrawn, because disappearance from a capped search window is not proof of either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know the event cost
&lt;/h2&gt;

&lt;p&gt;On Free, the Actor charges $0.015 per emitted listing, plus $0.01 when that returned listing has a phone. The start event is $0.00005 at 512 MB. Plan discounts apply; the pricing was not changed for this workflow.&lt;/p&gt;

&lt;p&gt;The three-row sample had two phones, so its Free-equivalent event cost is $0.06505. It was an owner test with no customer payment collected. A seed-only run or an unchanged repeat has no result or phone charge, but the start event remains. At the template's 25-row cap, even 25 rows with phones fit within its $0.75 charge limit at these prices.&lt;/p&gt;

&lt;p&gt;Use the real sample to inspect the digest, then start with a small search for your own market. Keep the monitor ID stable, investigate failed runs, and preserve the source links when sending the changes onward.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a citable Reddit corpus with SQLite and repeat imports</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:16:26 +0000</pubDate>
      <link>https://dev.to/benthepythondev/build-a-citable-reddit-corpus-with-sqlite-and-repeat-imports-1lp5</link>
      <guid>https://dev.to/benthepythondev/build-a-citable-reddit-corpus-with-sqlite-and-repeat-imports-1lp5</guid>
      <description>&lt;p&gt;A weekly Reddit archive refresh can return posts you've already collected. Appending every export creates duplicate documents; reading only &lt;code&gt;selftext&lt;/code&gt; drops the comments. This example keeps one document per Reddit identity and preserves the source link you need for a citation.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/benthepythondev/reddit-archive-scraper" rel="noopener noreferrer"&gt;Reddit Archive Scraper on Apify&lt;/a&gt;. The export below comes from a small cloud run on September 9, 2026. The importer uses Python's standard library and SQLite, so you can try the supplied sample locally before running an Actor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the sample walkthrough
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://api.apify.com/v2/key-value-stores/JVZVbAGpV28Wr4uqW/records/reddit-archive-walkthrough.mp4" rel="noopener noreferrer"&gt;Watch the 18-second walkthrough&lt;/a&gt;. This silent clip walks through the dated sample and repeat-import check below; it does not record a new cloud run. The written instructions contain the complete commands and output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a dated sample
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/benthepythondev/reddit-archive-scraper/examples/archive-historical-python-posts" rel="noopener noreferrer"&gt;Open the public Python archive example&lt;/a&gt;. It requests posts created on January 1, 2024, with a cap of three posts and two comments per post:&lt;br&gt;
&lt;/p&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;"subreddits"&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="s2"&gt;"Python"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"afterDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"beforeDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sortOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oldest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPosts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeComments"&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;"maxCommentsPerPost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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;Build &lt;code&gt;1.0.20&lt;/code&gt;, run &lt;code&gt;aQVygX8ydEuuDuwjM&lt;/code&gt;, returned &lt;strong&gt;three posts and six comments&lt;/strong&gt;. One post was “Monday Daily Thread: Project ideas!”, ID &lt;code&gt;18vkgtu&lt;/code&gt;, created at &lt;code&gt;2024-01-01T00:00:08+00:00&lt;/code&gt;. Its citation is the &lt;a href="https://www.reddit.com/r/Python/comments/18vkgtu/monday_daily_thread_project_ideas/" rel="noopener noreferrer"&gt;original Reddit thread&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That proves the bounded request returned usable records. The three-post limit cannot establish complete coverage of the day. The Actor reads PullPush and Arctic Shift, whose coverage and ingestion lag can vary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the identity and the text together
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;post:18vkgtu&lt;/code&gt; as the document key. Prefix comment IDs with &lt;code&gt;comment:&lt;/code&gt; because posts and comments have separate ID namespaces. The importer stores that key as SQLite's primary key:&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;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;record_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="n"&gt;document&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An identical import leaves the existing document alone. If a later export contains changed text, the same key updates in place. The database holds the latest imported version; it doesn't retain edit history or compare source versions to prevent an older export overwriting a newer one. Import exports in capture order.&lt;/p&gt;

&lt;p&gt;Each JSONL document contains &lt;code&gt;record_key&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;post_id&lt;/code&gt;, &lt;code&gt;parent_id&lt;/code&gt;, &lt;code&gt;subreddit&lt;/code&gt;, &lt;code&gt;created_iso&lt;/code&gt;, &lt;code&gt;source_url&lt;/code&gt; and &lt;code&gt;text&lt;/code&gt;. Post text combines the title and &lt;code&gt;selftext&lt;/code&gt;; comment text keeps the full &lt;code&gt;body&lt;/code&gt;. The script validates identities and Reddit permalinks, and rolls back the whole import if any row is malformed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it twice
&lt;/h2&gt;

&lt;p&gt;Download &lt;strong&gt;&lt;code&gt;reddit_archive_corpus.py&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;reddit-archive-sample.json&lt;/code&gt;&lt;/strong&gt; from the &lt;a href="https://gist.github.com/benthepythondev00/e28968a940fe4195ffb5a997fe309503" rel="noopener noreferrer"&gt;workflow files&lt;/a&gt;. The sample contains the nine real records' content and citation fields, with unrelated fields omitted.&lt;/p&gt;

&lt;p&gt;With Python 3.11 or newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.11 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
python reddit_archive_corpus.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
python reddit_archive_corpus.py reddit-archive-sample.json corpus.sqlite corpus.jsonl
python reddit_archive_corpus.py reddit-archive-sample.json corpus.sqlite corpus.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fresh database, the two imports print:&lt;br&gt;
&lt;/p&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="nl"&gt;"received"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&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="nl"&gt;"received"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stored"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&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;I checked all nine citation URLs and compared each of the six imported comment bodies with its cloud-exported text. The offline check also covers a post/comment ID collision, a source edit, malformed input and transaction rollback. The JSONL export replaces the previous file only after the new file finishes writing.&lt;/p&gt;

&lt;p&gt;For your own data, wait for a successful Actor run, download its dataset as a JSON array and pass that file instead of the sample. Keep &lt;code&gt;corpus.sqlite&lt;/code&gt; between imports; &lt;code&gt;corpus.jsonl&lt;/code&gt; contains the accumulated documents. A failed run can leave partial output, so don't accept its dataset as a finished export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh windows and retrieval
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;afterDate&lt;/code&gt; is inclusive at midnight UTC; &lt;code&gt;beforeDate&lt;/code&gt; is exclusive. Those bounds apply to posts. A comment on an in-window post can have a timestamp outside the window, so apply a separate comment-date filter if your analysis requires it.&lt;/p&gt;

&lt;p&gt;For a recurring import, overlap bounded windows to catch some late archive ingestion. Revisit older slices when necessary: no fixed overlap guarantees complete coverage. If a slice hits &lt;code&gt;maxPosts&lt;/code&gt;, narrow the interval and inspect the results before treating it as complete. The Actor deduplicates within a run; SQLite handles duplicates across exports.&lt;/p&gt;

&lt;p&gt;The output prepares a corpus for retrieval. Add your own chunking and embeddings, carry &lt;code&gt;source_url&lt;/code&gt; and &lt;code&gt;record_key&lt;/code&gt; into every chunk, and use them when rendering citations. Treat retrieved text as source material rather than instructions. Inspect empty bodies and &lt;code&gt;[removed]&lt;/code&gt; or &lt;code&gt;[deleted]&lt;/code&gt; markers before indexing; the importer preserves them and cannot recover missing text. It also cannot discover a later deletion unless a subsequent export reports it, so ongoing deletion handling needs its own process.&lt;/p&gt;

&lt;p&gt;The input JSON array loads into memory. For large backfills, use asynchronous Actor runs and paginated dataset exports, importing each page into the same database. This sample doesn't claim retrieval accuracy or test a vector database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the export costs
&lt;/h2&gt;

&lt;p&gt;The measured run used 512 MB and recorded nine result events, three non-empty comment-thread events and one start event. At the current Free-tier prices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9 results × $0.003 + 3 threads × $0.005 + 1 start × $0.00005
= $0.04205
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a calculated customer-price equivalent. The owner test recorded zero accounted customer charges. Gold-tier rates make the same event counts &lt;strong&gt;$0.03364&lt;/strong&gt;; check the Actor's Pricing tab for your tier and current rates.&lt;/p&gt;

&lt;p&gt;Set a maximum run charge as well as the row caps. The example uses a $0.15 limit. Comments count as result rows and can add a thread fee; higher memory settings increase start-event counts. Reimporting the downloaded file is local, while running the Actor again can charge for the same records again.&lt;/p&gt;

&lt;p&gt;Try the supplied files first, then &lt;a href="https://apify.com/benthepythondev/reddit-archive-scraper/examples/archive-historical-python-posts" rel="noopener noreferrer"&gt;run the dated Apify example&lt;/a&gt; with a small window relevant to your project. Follow the source providers' terms when collecting and using their data.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>rag</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Scrape Emails from Google Maps (a Pay-As-You-Go Apollo &amp; ZoomInfo Alternative)</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 14:26:57 +0000</pubDate>
      <link>https://dev.to/benthepythondev/how-to-scrape-emails-from-google-maps-a-pay-as-you-go-apollo-zoominfo-alternative-57no</link>
      <guid>https://dev.to/benthepythondev/how-to-scrape-emails-from-google-maps-a-pay-as-you-go-apollo-zoominfo-alternative-57no</guid>
      <description>&lt;p&gt;Google Maps is the biggest free B2B database on the planet — every local business, with its website, phone and address. The one thing it won't give you is the &lt;strong&gt;email&lt;/strong&gt;. So most people end up exporting businesses from one tool, then pasting domains into a second email-finder, then verifying in a third. Here's how to do the whole thing in one step, and pay only for what you get.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual workaround (and why it's painful)
&lt;/h2&gt;

&lt;p&gt;The common stack is: a Maps scraper → a spreadsheet → an email-finder → a verifier. Three tools, three bills, and a lot of copy-pasting. Tools like Apollo or ZoomInfo bundle this, but they're priced for enterprise (annual contracts, per-seat fees) and they're thin on local SMEs, especially outside the US.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-step approach
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/google-maps-email-scraper" rel="noopener noreferrer"&gt;Google Maps Email Scraper&lt;/a&gt;&lt;/strong&gt; does the search &lt;strong&gt;and&lt;/strong&gt; the email enrichment in a single run. Give it a niche + a city, and for every business it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scrapes the listing — name, phone, address, website, rating, categories.&lt;/li&gt;
&lt;li&gt;Visits that business's website — homepage, contact/Kontakt page and &lt;strong&gt;Impressum&lt;/strong&gt; — and pulls the &lt;strong&gt;published email address&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If nothing is public, tests common business inboxes (info@, sales@, kontakt@…) and validates them with MX and SMTP checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You get one clean row per lead with a &lt;code&gt;primary_email&lt;/code&gt;, a confidence score, and every email it found:&lt;br&gt;
&lt;/p&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Studio Berlin Marketing GmbH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+49 30 1234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"website"&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://www.studio-berlin.de"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"primary_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"info@studio-berlin.de"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_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;"impressum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lead_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;92&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;h2&gt;
  
  
  Why it's a strong Apollo / ZoomInfo alternative
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pay-as-you-go&lt;/strong&gt; — a small fee per lead, plus a premium &lt;strong&gt;only when an email is actually found&lt;/strong&gt;. No annual contract, no per-seat tax, no credit rationing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You own the data&lt;/strong&gt; — export to CSV/JSON or straight to your CRM via API, Make, Zapier or n8n.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh and targeted&lt;/strong&gt; — sourced live for your exact niche + city, not a stale shared database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Especially strong in the DACH market&lt;/strong&gt; — German sites are legally required to publish a contact email in their Impressum, so hit rates on German, Austrian and Swiss businesses are very high. Most US-centric tools are thin here.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Example: build a prospect list in one run
&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dentists"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Munich, Germany"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requireEmail"&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="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;&lt;code&gt;requireEmail: true&lt;/code&gt; means you only get leads that actually have an email — you're not charged the email fee for the ones without. Already have a list of companies? Switch to &lt;code&gt;websites&lt;/code&gt; mode and paste your domains to enrich them directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;p&gt;This finds &lt;strong&gt;business&lt;/strong&gt; contact emails (the info@/sales@/Impressum kind), not a specific named person's inbox — for that, pair it with a &lt;a href="https://apify.com/benthepythondev/smart-email-finder-verifier" rel="noopener noreferrer"&gt;name-based email finder&lt;/a&gt;. Published addresses are real and high-confidence; role-based guesses are validated by MX (and SMTP where the network allows), so filter by &lt;code&gt;email_confidence&lt;/code&gt; if you only want the surest ones. And use the data for legitimate B2B outreach — honor GDPR/CAN-SPAM and opt-outs.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt; No — a search query + location is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it work outside Germany?&lt;/strong&gt; Yes, anywhere Google Maps covers; the Impressum edge just makes DACH hit rates exceptionally high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I schedule it?&lt;/strong&gt; Yes — recurring runs and full API/Make/Zapier/n8n integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does it cost?&lt;/strong&gt; Per result, with the email premium charged only when an email is found — far below an enterprise seat for the same verified-lead output.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Build verified B2B lead lists straight from Google Maps with the &lt;a href="https://apify.com/benthepythondev/google-maps-email-scraper" rel="noopener noreferrer"&gt;Google Maps Email Scraper&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>marketing</category>
    </item>
    <item>
      <title>ImmoScout24 Alternative: German Real-Estate Data Without the Blocks</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 13:31:22 +0000</pubDate>
      <link>https://dev.to/benthepythondev/immoscout24-alternative-german-real-estate-data-without-the-blocks-llc</link>
      <guid>https://dev.to/benthepythondev/immoscout24-alternative-german-real-estate-data-without-the-blocks-llc</guid>
      <description>&lt;p&gt;ImmoScout24 is Germany's #1 property portal — and one of the hardest to scrape, with&lt;br&gt;
aggressive blocking and pricey API access. The good news: most of the same German&lt;br&gt;
real-estate data is available from sources that are &lt;strong&gt;far more accessible&lt;/strong&gt;, including&lt;br&gt;
private-landlord inventory ImmoScout under-represents. Here are three ImmoScout24&lt;br&gt;
alternatives for actual data, by use case.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Private-landlord rentals &amp;amp; sales → Kleinanzeigen
&lt;/h2&gt;

&lt;p&gt;Kleinanzeigen is where private owners list directly — often cheaper and absent from&lt;br&gt;
the big portals. The&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/kleinanzeigen-immobilien-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Immobilien Scraper&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
parses rooms, living space, Kaltmiete/Warmmiete, deposit, address and PLZ into clean&lt;br&gt;
fields, with city/radius search.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Agent-listed properties → Immowelt
&lt;/h2&gt;

&lt;p&gt;For broader agent-listed inventory, the&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/immowelt-scraper" rel="noopener noreferrer"&gt;Immowelt Scraper&lt;/a&gt;&lt;/strong&gt; pulls&lt;br&gt;
rental and sale listings with price, size, rooms, location and details — a strong&lt;br&gt;
ImmoScout substitute for market analysis.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Shared flats &amp;amp; rentals (WGs) → WG-Gesucht
&lt;/h2&gt;

&lt;p&gt;For the shared-apartment and rental market (huge in German cities), the&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/wg-gesucht-scraper" rel="noopener noreferrer"&gt;WG-Gesucht Scraper&lt;/a&gt;&lt;/strong&gt; extracts&lt;br&gt;
room/flat listings with price, location and availability — inventory you won't find on&lt;br&gt;
ImmoScout at all.&lt;br&gt;
&lt;/p&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;"locationCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"münchen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"radiusKm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&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;h2&gt;
  
  
  Why these beat fighting ImmoScout24
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accessible&lt;/strong&gt; — they don't wall you off the way ImmoScout does, so runs finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different inventory&lt;/strong&gt; — private-landlord and WG listings ImmoScout under-covers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay-as-you-go&lt;/strong&gt; — per-result pricing, no portal API contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean, typed fields&lt;/strong&gt; — rooms, m², rent, deposit, address — ready for analysis.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest note
&lt;/h2&gt;

&lt;p&gt;These are alternative &lt;em&gt;sources&lt;/em&gt; of German property data, not ImmoScout24 scrapers. If&lt;br&gt;
you specifically need ImmoScout's listings, you'll need ImmoScout. For &lt;strong&gt;market&lt;br&gt;
analysis, private-landlord leads, rentals and WGs&lt;/strong&gt;, this trio is cheaper, more&lt;br&gt;
reliable, and covers inventory the big portal misses. (For Austria, add&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/willhaben-scraper" rel="noopener noreferrer"&gt;willhaben&lt;/a&gt;&lt;/strong&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt; No — give a city/PLZ and your filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rent and buy?&lt;/strong&gt; Yes — Kleinanzeigen and Immowelt cover both; WG-Gesucht covers shared/rentals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I search by city?&lt;/strong&gt; Yes — city names auto-resolve; radius search supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it legal?&lt;/strong&gt; These read publicly available listing data. Use responsibly and within&lt;br&gt;
each site's terms and applicable law.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;German real-estate data without the ImmoScout wall: &lt;a href="https://apify.com/benthepythondev/kleinanzeigen-immobilien-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Immobilien&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/immowelt-scraper" rel="noopener noreferrer"&gt;Immowelt&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/wg-gesucht-scraper" rel="noopener noreferrer"&gt;WG-Gesucht&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>realestate</category>
      <category>germany</category>
      <category>webscraping</category>
      <category>proptech</category>
    </item>
    <item>
      <title>Phantombuster Alternative: Pay-As-You-Go Social Media Data Extraction</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 13:27:32 +0000</pubDate>
      <link>https://dev.to/benthepythondev/phantombuster-alternative-pay-as-you-go-social-media-data-extraction-3p04</link>
      <guid>https://dev.to/benthepythondev/phantombuster-alternative-pay-as-you-go-social-media-data-extraction-3p04</guid>
      <description>&lt;p&gt;Phantombuster is a handy social-automation suite — but it's subscription-based with&lt;br&gt;
execution-time limits, and a lot of people only use it for one thing: &lt;strong&gt;pulling social&lt;br&gt;
media data&lt;/strong&gt; (posts, profiles, followers). If that's you, you can do the data side&lt;br&gt;
pay-as-you-go, with no monthly seat and no time budget to ration.&lt;/p&gt;
&lt;h2&gt;
  
  
  What most people actually use it for
&lt;/h2&gt;

&lt;p&gt;Extracting structured data from social platforms — a creator's posts, a profile's&lt;br&gt;
stats, a follower list, hashtag/keyword streams. That's a scraping job, and you can&lt;br&gt;
price it per result instead of renting hours.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pay-as-you-go data stack
&lt;/h2&gt;

&lt;p&gt;Pick the network:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/bluesky-scraper" rel="noopener noreferrer"&gt;Bluesky Scraper&lt;/a&gt;&lt;/strong&gt; — posts, profiles, followers, search (no login needed for most).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/mastodon-scraper" rel="noopener noreferrer"&gt;Mastodon Scraper&lt;/a&gt;&lt;/strong&gt; — hashtags, accounts, trends from any instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/instagram-intelligence-scraper" rel="noopener noreferrer"&gt;Instagram Intelligence Scraper&lt;/a&gt;&lt;/strong&gt; — profiles, posts, comments, hashtags + lead scoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/tiktok-profile-scraper" rel="noopener noreferrer"&gt;TikTok Profile Scraper&lt;/a&gt;&lt;/strong&gt; — public profile stats at scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/reddit-scraper" rel="noopener noreferrer"&gt;Reddit Scraper&lt;/a&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/lemmy-scraper" rel="noopener noreferrer"&gt;Lemmy Scraper&lt;/a&gt;&lt;/strong&gt; for discussion data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each returns clean JSON (text, engagement counts, author, media) ready for analysis or&lt;br&gt;
an LLM:&lt;br&gt;
&lt;/p&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"searchTerms"&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="s2"&gt;"#yourbrand"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&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;h2&gt;
  
  
  Why this beats a subscription
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-result pricing&lt;/strong&gt; — pay for the data you pull, no monthly seat or execution-time cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the data&lt;/strong&gt; — export to CSV/JSON, no lock-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One consistent shape&lt;/strong&gt; across networks — easy to pipe into a warehouse or LLM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emerging networks covered&lt;/strong&gt; — Bluesky/Mastodon/Lemmy, where competition is thin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;p&gt;Phantombuster also does &lt;em&gt;automation&lt;/em&gt; (auto-connect, auto-message — mostly LinkedIn&lt;br&gt;
growth flows). These actors don't automate actions on your account; they &lt;strong&gt;extract&lt;br&gt;
data&lt;/strong&gt;. If you need account automation, that's a different (and ToS-riskier) job. For&lt;br&gt;
clean social-data extraction and listening, pay-as-you-go scraping is cheaper and&lt;br&gt;
simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need logins?&lt;/strong&gt; Mostly no (Bluesky/Mastodon/Lemmy public data); keyword search on&lt;br&gt;
some networks works best with an optional app token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I schedule it?&lt;/strong&gt; Yes — recurring runs + API/Make/Zapier/n8n.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which networks?&lt;/strong&gt; Bluesky, Mastodon, Lemmy, Instagram, TikTok, Reddit today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it legal?&lt;/strong&gt; It reads publicly available data via public APIs/pages. Use it&lt;br&gt;
responsibly and within each platform's terms.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Extract social data pay-as-you-go: &lt;a href="https://apify.com/benthepythondev/bluesky-scraper" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/mastodon-scraper" rel="noopener noreferrer"&gt;Mastodon&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/instagram-intelligence-scraper" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/tiktok-profile-scraper" rel="noopener noreferrer"&gt;TikTok&lt;/a&gt;, &lt;a href="https://apify.com/benthepythondev/reddit-scraper" rel="noopener noreferrer"&gt;Reddit&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>socialmedia</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>data</category>
    </item>
    <item>
      <title>ZoomInfo Alternative: Build B2B Contact Data Without the Enterprise Contract</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 13:23:52 +0000</pubDate>
      <link>https://dev.to/benthepythondev/zoominfo-alternative-build-b2b-contact-data-without-the-enterprise-contract-lm8</link>
      <guid>https://dev.to/benthepythondev/zoominfo-alternative-build-b2b-contact-data-without-the-enterprise-contract-lm8</guid>
      <description>&lt;p&gt;ZoomInfo is powerful — and priced for enterprises, with annual contracts that start in&lt;br&gt;
the five figures. If what you actually need is &lt;strong&gt;targeted company + contact data&lt;/strong&gt;&lt;br&gt;
(businesses in a vertical and region, with phone, website and email), you can source&lt;br&gt;
it yourself from live public data for a tiny fraction of the cost, and own every row.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you're really paying ZoomInfo for
&lt;/h2&gt;

&lt;p&gt;A giant prebuilt contact database with intent signals and org charts, billed annually&lt;br&gt;
per seat. That's overkill (and over-budget) if your job is &lt;strong&gt;building targeted&lt;br&gt;
prospect lists&lt;/strong&gt; for outbound — the most common use case by far.&lt;/p&gt;
&lt;h2&gt;
  
  
  The build-your-own stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Find the companies.&lt;/strong&gt; Pick your source by market:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/google-maps-business-scraper" rel="noopener noreferrer"&gt;Google Maps Business Scraper&lt;/a&gt;&lt;/strong&gt; — any niche + city (name, address, phone, website, rating) with a lead score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/gelbe-seiten-scraper" rel="noopener noreferrer"&gt;Gelbe Seiten Scraper&lt;/a&gt;&lt;/strong&gt; (Germany) and &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/business-contact-scraper" rel="noopener noreferrer"&gt;local.ch Scraper&lt;/a&gt;&lt;/strong&gt; (Switzerland) for DACH B2B directories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Get the emails.&lt;/strong&gt; Feed the companies' domains into the&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/smart-email-finder-verifier" rel="noopener noreferrer"&gt;Smart Email Finder &amp;amp; Verifier&lt;/a&gt;&lt;/strong&gt; —&lt;br&gt;
it tests common patterns, runs SMTP/MX checks, flags catch-all domains and returns a&lt;br&gt;
confidence score, so your list is deliverable before you send.&lt;br&gt;
&lt;/p&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;"search"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"logistics companies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hamburg, Germany"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&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;h2&gt;
  
  
  Why this beats an enterprise seat
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pay-as-you-go&lt;/strong&gt; — per result, no annual contract or per-seat tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You own the data&lt;/strong&gt; — export to CSV/CRM, no usage caps or credit rationing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh &amp;amp; targeted&lt;/strong&gt; — sourced live for your exact ICP, not a stale shared DB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DACH-strong&lt;/strong&gt; — most US-centric tools are thin on German/Swiss SMEs; directory
scrapers aren't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;p&gt;ZoomInfo's edge is scale, intent data and org hierarchy. If you need buying-intent&lt;br&gt;
signals or a pre-enriched database of millions, that's a different product. For&lt;br&gt;
&lt;strong&gt;building targeted, verified outbound lists on demand&lt;/strong&gt; — especially in DACH — live&lt;br&gt;
sourcing + verification is dramatically cheaper and just as actionable.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this GDPR-compliant?&lt;/strong&gt; You're collecting publicly listed business contact data;&lt;br&gt;
use it for legitimate B2B outreach and honor opt-outs and local law.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt; No for the directory/Maps scrapers or the email finder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I automate it?&lt;/strong&gt; Yes — schedule runs and pipe results to your CRM via API, Make,&lt;br&gt;
Zapier or n8n.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How accurate are the emails?&lt;/strong&gt; SMTP/MX-verified with a confidence score; drop the&lt;br&gt;
low-confidence ones before sending.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Build B2B lists on demand: &lt;a href="https://apify.com/benthepythondev/google-maps-business-scraper" rel="noopener noreferrer"&gt;Google Maps Business Scraper&lt;/a&gt; + &lt;a href="https://apify.com/benthepythondev/smart-email-finder-verifier" rel="noopener noreferrer"&gt;Smart Email Finder &amp;amp; Verifier&lt;/a&gt;, plus &lt;a href="https://apify.com/benthepythondev/gelbe-seiten-scraper" rel="noopener noreferrer"&gt;Gelbe Seiten&lt;/a&gt; and &lt;a href="https://apify.com/benthepythondev/business-contact-scraper" rel="noopener noreferrer"&gt;local.ch&lt;/a&gt; for DACH.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>leadgeneration</category>
      <category>sales</category>
      <category>b2b</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>mobile.de Alternative: Get Private-Seller German Used-Car Data</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 11:19:18 +0000</pubDate>
      <link>https://dev.to/benthepythondev/mobilede-alternative-get-private-seller-german-used-car-data-2381</link>
      <guid>https://dev.to/benthepythondev/mobilede-alternative-get-private-seller-german-used-car-data-2381</guid>
      <description>&lt;p&gt;mobile.de is the default for German used-car data — but it's &lt;strong&gt;dealer-dominated&lt;/strong&gt;,&lt;br&gt;
heavily defended, and you're seeing the same inventory every other dealer and tool&lt;br&gt;
sees. The biggest blind spot it leaves is &lt;strong&gt;private sellers&lt;/strong&gt;, who list on&lt;br&gt;
Kleinanzeigen (formerly eBay Kleinanzeigen). That's where the arbitrage, the cheaper&lt;br&gt;
cars and the motivated sellers are — and it's far more accessible to pull.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why private-seller data is the edge
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different inventory&lt;/strong&gt; — private owners list cheaper and earlier than dealers; it's
the half of the market mobile.de under-represents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arbitrage &amp;amp; sourcing&lt;/strong&gt; — dealers and flippers buy private and resell; this is the
sourcing side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaner economics&lt;/strong&gt; — Kleinanzeigen is easier to access reliably than mobile.de's
defenses.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/kleinanzeigen-autos-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Autos Scraper&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
parses the German car attributes most tools drop — &lt;strong&gt;Marke (make), Modell,&lt;br&gt;
Erstzulassung (year), Kilometerstand (mileage), Kraftstoff (fuel), Getriebe&lt;br&gt;
(gearbox), Leistung (power), price&lt;/strong&gt; — into clean, typed fields, with city/PLZ +&lt;br&gt;
radius search.&lt;br&gt;
&lt;/p&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;"locationCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"berlin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minYear"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2016&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxMileage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120000&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;Output is one tidy row per car — make, model, year, mileage, fuel, transmission,&lt;br&gt;
power, price, location, URL — ready for price analysis, arbitrage screening or a&lt;br&gt;
search product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pair it for full DACH coverage
&lt;/h2&gt;

&lt;p&gt;Used cars are one slice of the DACH market. The same engine powers the&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/kleinanzeigen-immobilien-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Immobilien Scraper&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
(real estate) and &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/kleinanzeigen-jobs-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Jobs Scraper&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
(local jobs), plus &lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/willhaben-scraper" rel="noopener noreferrer"&gt;willhaben&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
for Austria — so you can cover the German-speaking market with one consistent data shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest note
&lt;/h2&gt;

&lt;p&gt;This complements mobile.de rather than replacing it: mobile.de still has the deepest&lt;br&gt;
&lt;em&gt;dealer&lt;/em&gt; inventory. For &lt;strong&gt;private-seller&lt;/strong&gt; cars, market-price analysis and sourcing,&lt;br&gt;
Kleinanzeigen is the better, more accessible source — and it's the half most people miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt; No — give a location and optional filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I filter by make/model?&lt;/strong&gt; Best-effort by title keyword; for exact make filtering,&lt;br&gt;
paste a Kleinanzeigen search URL with the make facet selected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What fields do I get?&lt;/strong&gt; Make, model, year, first registration, mileage, price, fuel,&lt;br&gt;
transmission, power, condition, color and location, where the listing provides them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it legal?&lt;/strong&gt; It reads publicly available listing data. Use it responsibly and&lt;br&gt;
within applicable laws and Kleinanzeigen's terms.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Get the private-seller side of the German car market with the &lt;a href="https://apify.com/benthepythondev/kleinanzeigen-autos-scraper" rel="noopener noreferrer"&gt;Kleinanzeigen Autos Scraper&lt;/a&gt; — a data complement to mobile.de.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>germany</category>
      <category>automotive</category>
      <category>data</category>
    </item>
    <item>
      <title>Zillow Alternative: Free Real-Estate Data Sources That Don't Block You</title>
      <dc:creator>Ben</dc:creator>
      <pubDate>Sat, 27 Jun 2026 11:15:59 +0000</pubDate>
      <link>https://dev.to/benthepythondev/zillow-alternative-free-real-estate-data-sources-that-dont-block-you-41l5</link>
      <guid>https://dev.to/benthepythondev/zillow-alternative-free-real-estate-data-sources-that-dont-block-you-41l5</guid>
      <description>&lt;p&gt;Everyone tries to scrape Zillow first — and hits a wall of CAPTCHAs, bans and a $300–&lt;br&gt;
$800/month lead bill. The smarter move is to pull comparable real-estate data from&lt;br&gt;
sources that are &lt;strong&gt;far easier to access&lt;/strong&gt; and often carry inventory Zillow doesn't.&lt;br&gt;
Here are three Zillow alternatives for actual data, by use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Off-market &amp;amp; FSBO leads → Craigslist
&lt;/h2&gt;

&lt;p&gt;Zillow's leads are bought by every agent in your zip. Craigslist is full of&lt;br&gt;
&lt;strong&gt;for-sale-by-owner&lt;/strong&gt; sellers with no agent — exclusive, direct-contact leads that&lt;br&gt;
aren't on the MLS. The&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/craigslist-real-estate-scraper" rel="noopener noreferrer"&gt;Craigslist Real Estate Scraper&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
pulls price, beds, baths, sqft, photos, contact availability and a 0–100 lead score,&lt;br&gt;
with an owner-only filter for pure FSBO.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. US homes for sale, rent &amp;amp; sold comps → Redfin
&lt;/h2&gt;

&lt;p&gt;For structured listing data and &lt;strong&gt;sold comps&lt;/strong&gt; (the part Zillow gates hardest), Redfin&lt;br&gt;
is more accessible. The&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/redfin-scraper" rel="noopener noreferrer"&gt;Redfin Scraper&lt;/a&gt;&lt;/strong&gt; returns price,&lt;br&gt;
beds, baths, sqft, address and photos across for-sale, rental and sold — clean enough&lt;br&gt;
for valuation models and market dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. International / UK property → OnTheMarket
&lt;/h2&gt;

&lt;p&gt;Zillow is US-only. For the UK, the&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/benthepythondev/onthemarket-scraper" rel="noopener noreferrer"&gt;OnTheMarket Scraper&lt;/a&gt;&lt;/strong&gt; pulls&lt;br&gt;
for-sale and to-rent listings with price, beds, type, address, agent and even&lt;br&gt;
latitude/longitude — without the heavy blocking Rightmove and Zoopla throw up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these beat fighting Zillow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They don't ban you on sight&lt;/strong&gt; — Craigslist, Redfin and OnTheMarket are far more
scrapable than Zillow, so runs actually finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different, exclusive inventory&lt;/strong&gt; — FSBO and off-market deals Zillow never shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay-as-you-go&lt;/strong&gt; — per-result pricing instead of a $300–800/month lead contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the data&lt;/strong&gt; — export to CSV/Excel/JSON for your CRM, model or product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest note
&lt;/h2&gt;

&lt;p&gt;These aren't Zillow scrapers — they're alternative &lt;em&gt;sources&lt;/em&gt; of comparable data&lt;br&gt;
(listings, comps, leads). If you specifically need Zillow's Zestimate, you'll need&lt;br&gt;
Zillow. For everything else — leads, listings, comps, market trends — these are&lt;br&gt;
cheaper, more reliable, and often richer.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt; No — give a city/location and your filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get sold comps?&lt;/strong&gt; Yes — via the Redfin scraper (for-sale, rent and sold).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FSBO only?&lt;/strong&gt; Yes — the Craigslist scraper has an owner-only filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it legal?&lt;/strong&gt; These read publicly available listing data. Use responsibly and&lt;br&gt;
within each site's terms and applicable law.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Real-estate data without the Zillow wall: &lt;a href="https://apify.com/benthepythondev/craigslist-real-estate-scraper" rel="noopener noreferrer"&gt;Craigslist Real Estate&lt;/a&gt; (FSBO), &lt;a href="https://apify.com/benthepythondev/redfin-scraper" rel="noopener noreferrer"&gt;Redfin&lt;/a&gt; (US comps), &lt;a href="https://apify.com/benthepythondev/onthemarket-scraper" rel="noopener noreferrer"&gt;OnTheMarket&lt;/a&gt; (UK).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>realestate</category>
      <category>webscraping</category>
      <category>zillow</category>
      <category>proptech</category>
    </item>
  </channel>
</rss>
