<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://automateordie.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://automateordie.dev/" rel="alternate" type="text/html" /><updated>2026-06-18T00:10:56-07:00</updated><id>https://automateordie.dev/feed.xml</id><title type="html">Nothing In Particular</title><subtitle>Why spend 5 minutes doing something manually when you can spend 5 days trying to automate it?</subtitle><author><name>Sahib Bhai</name></author><entry><title type="html">Wheretolivedotla</title><link href="https://automateordie.dev/wheretolivedotla/" rel="alternate" type="text/html" title="Wheretolivedotla" /><published>2023-08-26T00:00:00-07:00</published><updated>2023-08-26T00:00:00-07:00</updated><id>https://automateordie.dev/wheretolivedotla</id><content type="html" xml:base="https://automateordie.dev/wheretolivedotla/"><![CDATA[<h1 id="wheretolivela">WhereToLive.LA</h1>

<h2 id="you-can-view-my-source-code-on-github-the-website-is-located-at-httpswheretolivela">You can view <a href="https://github.com/perfectly-preserved-pie/larentals">my source code</a> on GitHub. The website is located at <a href="https://wheretolive.la">https://wheretolive.la</a>.</h2>

<h3 id="the-website-is-mobile-friendly-enough-but-i-still-recommend-viewing-it-on-a-device-with-a-large-screen-tablet-laptop-etc">The website is mobile-friendly (enough) but I still recommend viewing it on a device with a large screen (tablet, laptop, etc.)</h3>

<hr />

<p>I’ve been looking to move into a new place for a while now and have been amassing resources and websites that I can peruse through to find the right place.
One of the moderators on <a href="https://www.reddit.com/r/LARentals/">the /r/LArentals subreddit</a> posts <a href="https://docs.google.com/spreadsheets/d/1gBLt73zziGg41IUS3FdqU4ddULWV5sFaSRawLyO5YyY/edit#gid=80625330">a spreadsheet</a> of new rental properties every week.</p>

<p>As you can see, <a href="https://user-images.githubusercontent.com/28774550/188333676-11846918-2c1d-4ebd-aa92-897fc2f18dfa.png">it’s quite detailed and organized.</a></p>

<p>You can filter by any column to narrow down the results list. I absolutely loved that; Zillow, RedFin, etc. don’t have as quite a granular filter as this spreadsheet does.</p>

<p>But I was a little miffed that I kept having to open new tabs and paste the street address into Google to find out where in LA the property resided. At 350+ rows, that’s a LOT of browser tabs and LA County is such a massive sprawl that I simply can’t visualize where every property is.</p>

<h2 id="the-idea">The Idea</h2>
<p>So I thought, “why not just visually map every street address on Google Maps?” And then that idea became “the map should be filterable in all the same ways as the spreadsheet.”
So I wanted a filterable map. And that’s how I fell down the rabbit hole. Using a combination of:</p>

<ul>
  <li>BeautifulSoup 4</li>
  <li>Dash Leaflet</li>
  <li>Dash Bootstrap Components</li>
  <li>GeoPy</li>
  <li>ImageKit</li>
  <li>Pandas</li>
</ul>

<p>I made an interactive map that displays and filters all the rental properties listed. Eventually, <a href="https://wheretolive.la/for-sale">I expanded that to for-sale listings under $1,000,000</a> (those still exist in LA somehow) since that same person also posts a spreadsheet with that information too.</p>

<h3 id="data-handling">Data Handling</h3>
<p>Because the spreadsheet was already in CSV form, Pandas was an obvious choice here. I could simply just read it into a dataframe and add columns and manipulate the data in whatever way I needed to.</p>

<h3 id="geocoding">Geocoding</h3>
<p>I first needed some kind of API that I could feed street addresses from the spreadsheet and it would spit out the assoicated coordinates. I intially spun up an instance of Nominatim because it was free and easy and tried that. Unfortunately, quite a few addresses just simply wouldn’t resolve in Nominatim but resolved just fine with the Google Maps API. So I switched over to Google Maps, which provides <a href="https://mapsplatform.google.com/pricing/">a generous free tier</a> ($200/month).
I haven’t had any issues since then; it handled every address I threw at it and returned me accurate coordinates.</p>

<p>I created a quick function to return coordinates based on a provided street address:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Create a function to get coordinates from the full street address
</span><span class="k">def</span> <span class="nf">return_coordinates</span><span class="p">(</span><span class="n">address</span><span class="p">,</span> <span class="n">row_index</span><span class="p">):</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">geocode_info</span> <span class="o">=</span> <span class="n">g</span><span class="p">.</span><span class="n">geocode</span><span class="p">(</span><span class="n">address</span><span class="p">,</span> <span class="n">components</span><span class="o">=</span><span class="p">{</span><span class="s">'administrative_area'</span><span class="p">:</span> <span class="s">'CA'</span><span class="p">,</span> <span class="s">'country'</span><span class="p">:</span> <span class="s">'US'</span><span class="p">})</span>
        <span class="n">lat</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">geocode_info</span><span class="p">.</span><span class="n">latitude</span><span class="p">)</span>
        <span class="n">lon</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">geocode_info</span><span class="p">.</span><span class="n">longitude</span><span class="p">)</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="n">lat</span> <span class="o">=</span> <span class="n">NaN</span>
        <span class="n">lon</span> <span class="o">=</span> <span class="n">NaN</span>
        <span class="n">logger</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't fetch geocode information for </span><span class="si">{</span><span class="n">address</span><span class="si">}</span><span class="s"> (row </span><span class="si">{</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="si">}</span><span class="s"> of </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">df</span><span class="p">)</span><span class="si">}</span><span class="s">) because of </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">."</span><span class="p">)</span>
    <span class="n">logger</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="sa">f</span><span class="s">"Fetched coordinates </span><span class="si">{</span><span class="n">lat</span><span class="si">}</span><span class="s">, </span><span class="si">{</span><span class="n">lon</span><span class="si">}</span><span class="s"> for </span><span class="si">{</span><span class="n">address</span><span class="si">}</span><span class="s"> (row </span><span class="si">{</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="si">}</span><span class="s"> of </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">df</span><span class="p">)</span><span class="si">}</span><span class="s">)."</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">lat</span><span class="p">,</span> <span class="n">lon</span>
</code></pre></div></div>
<p>You’ll see that I had to add <code class="language-plaintext highlighter-rouge">CA</code> and <code class="language-plaintext highlighter-rouge">US</code> as conditions within <code class="language-plaintext highlighter-rouge">components</code>; this ensures that all results are restricted to California, USA. I was getting some coordinates that were all the way in China, some in Africa, and some in the middle of the Atlantic Ocean.
I’m not entirely sure why, but part of the reason for that is that there are cities with the same names in other states/countries. For example: Lancaster, MA.</p>

<p>Putting these constraints in ensures that I get the right coordinates for the California city.</p>

<p>Then I iterated over every row with that function:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Iterate through the dataframe and fetch coordinates for rows that don't have them
# If the Coordinates column is already present, iterate through the null cells
# Similiar to above, we can use the presence of the Coordinates column as a proxy for Longitude and Latitude; all 3 should exist together or none at all
# This assumption will reduce the number of API calls to Google Maps
</span><span class="k">if</span> <span class="s">'Coordinates'</span> <span class="ow">in</span> <span class="n">df</span><span class="p">.</span><span class="n">columns</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">df</span><span class="p">[</span><span class="s">'Coordinates'</span><span class="p">].</span><span class="n">isnull</span><span class="p">().</span><span class="n">itertuples</span><span class="p">():</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Grabbing coordinates for row #</span><span class="si">{</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="si">}</span><span class="s">..."</span><span class="p">)</span>
        <span class="n">coordinates</span> <span class="o">=</span> <span class="n">return_coordinates</span><span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Full Street Address'</span><span class="p">])</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Latitude'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Longitude'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Coordinates'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>
<span class="c1"># If the Coordinates column doesn't exist (i.e this is a first run), create it using df.at
</span><span class="k">elif</span> <span class="s">'Coordinates'</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">df</span><span class="p">.</span><span class="n">columns</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">df</span><span class="p">.</span><span class="n">itertuples</span><span class="p">():</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Grabbing coordinates for row #</span><span class="si">{</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="si">}</span><span class="s">..."</span><span class="p">)</span>
        <span class="n">coordinates</span> <span class="o">=</span> <span class="n">return_coordinates</span><span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Full Street Address'</span><span class="p">])</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Latitude'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Longitude'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
        <span class="n">df</span><span class="p">.</span><span class="n">at</span><span class="p">[</span><span class="n">row</span><span class="p">.</span><span class="n">Index</span><span class="p">,</span> <span class="s">'Coordinates'</span><span class="p">]</span> <span class="o">=</span> <span class="n">coordinates</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>
</code></pre></div></div>
<h3 id="filters">Filters</h3>
<p>So I had the map and markers ready, but how could I filter the markers depending on different variables? Luckily, that’s what Dash does via “<a href="https://dash.plotly.com/basic-callbacks">callbacks</a>”. A box could be checked or a slider could be dragged, and that would cause an action on the backend to fire off. In my case, a checked box would need to change the dataframe; the dataframe is how the markers on the map are being populated. So if I wanted to see ONLY condos, I would need to query the dataframe for ONLY condos.</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">callback</span><span class="p">(</span>
  <span class="n">Output</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'lease_geojson'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'children'</span><span class="p">),</span>
  <span class="p">[</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'subtype_checklist'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'pets_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'terms_checklist'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'garage_spaces_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'rental_price_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'bedrooms_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'bathrooms_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'sqft_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'yrbuilt_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'sqft_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'yrbuilt_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'garage_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'ppsqft_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'ppsqft_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'furnished_checklist'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'security_deposit_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'security_deposit_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'pet_deposit_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'pet_deposit_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'key_deposit_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'key_deposit_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'other_deposit_slider'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'other_deposit_missing_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'listed_date_datepicker'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'start_date'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'listed_date_datepicker'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'end_date'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'listed_date_radio'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
    <span class="n">Input</span><span class="p">(</span><span class="n">component_id</span><span class="o">=</span><span class="s">'laundry_checklist'</span><span class="p">,</span> <span class="n">component_property</span><span class="o">=</span><span class="s">'value'</span><span class="p">),</span>
  <span class="p">]</span>
<span class="p">)</span>
<span class="c1"># The following function arguments are positional related to the Inputs in the callback above
# Their order must match
</span><span class="k">def</span> <span class="nf">update_map</span><span class="p">(</span><span class="n">subtypes_chosen</span><span class="p">,</span> <span class="n">pets_chosen</span><span class="p">,</span> <span class="n">terms_chosen</span><span class="p">,</span> <span class="n">garage_spaces</span><span class="p">,</span> <span class="n">rental_price</span><span class="p">,</span> <span class="n">bedrooms_chosen</span><span class="p">,</span> <span class="n">bathrooms_chosen</span><span class="p">,</span> <span class="n">sqft_chosen</span><span class="p">,</span> <span class="n">years_chosen</span><span class="p">,</span> <span class="n">sqft_missing_radio_choice</span><span class="p">,</span> <span class="n">yrbuilt_missing_radio_choice</span><span class="p">,</span> <span class="n">garage_missing_radio_choice</span><span class="p">,</span> <span class="n">ppsqft_chosen</span><span class="p">,</span> <span class="n">ppsqft_missing_radio_choice</span><span class="p">,</span> <span class="n">furnished_choice</span><span class="p">,</span> <span class="n">security_deposit_chosen</span><span class="p">,</span> <span class="n">security_deposit_radio_choice</span><span class="p">,</span> <span class="n">pet_deposit_chosen</span><span class="p">,</span> <span class="n">pet_deposit_radio_choice</span><span class="p">,</span> <span class="n">key_deposit_chosen</span><span class="p">,</span> <span class="n">key_deposit_radio_choice</span><span class="p">,</span> <span class="n">other_deposit_chosen</span><span class="p">,</span> <span class="n">other_deposit_radio_choice</span><span class="p">,</span> <span class="n">listed_date_datepicker_start</span><span class="p">,</span> <span class="n">listed_date_datepicker_end</span><span class="p">,</span> <span class="n">listed_date_radio</span><span class="p">,</span> <span class="n">laundry_chosen</span><span class="p">):</span>
  <span class="c1"># Pre-sort our various lists of strings for faster performance
</span>  <span class="n">subtypes_chosen</span><span class="p">.</span><span class="n">sort</span><span class="p">()</span>
  <span class="n">df_filtered</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span>
    <span class="n">subtype_function</span><span class="p">(</span><span class="n">subtypes_chosen</span><span class="p">)</span> <span class="o">&amp;</span>
    <span class="n">pets_radio_button</span><span class="p">(</span><span class="n">pets_chosen</span><span class="p">)</span> <span class="o">&amp;</span>
    <span class="n">terms_function</span><span class="p">(</span><span class="n">terms_chosen</span><span class="p">)</span> <span class="o">&amp;</span>
    <span class="c1"># For the slider, we need to filter the dataframe by an integer range this time and not a string like the ones aboves
</span>    <span class="c1"># To do this, we can use the Pandas .between function
</span>    <span class="c1"># See https://stackoverflow.com/a/40442778
</span>    <span class="p">((</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'garage_spaces'</span><span class="p">)[</span><span class="s">'garage_spaces'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">garage_spaces</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">garage_spaces</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">|</span> <span class="n">garage_radio_button</span><span class="p">(</span><span class="n">garage_missing_radio_choice</span><span class="p">,</span> <span class="n">garage_spaces</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">garage_spaces</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="c1"># Repeat but for rental price
</span>    <span class="c1"># Also pre-sort our lists of values to improve the performance of .between()
</span>    <span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'list_price'</span><span class="p">)[</span><span class="s">'list_price'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">rental_price</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">rental_price</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'Bedrooms'</span><span class="p">)[</span><span class="s">'Bedrooms'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">bedrooms_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">bedrooms_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'Total Bathrooms'</span><span class="p">)[</span><span class="s">'Total Bathrooms'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">bathrooms_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">bathrooms_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="p">((</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'Sqft'</span><span class="p">)[</span><span class="s">'Sqft'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">sqft_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">sqft_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">|</span> <span class="n">sqft_radio_button</span><span class="p">(</span><span class="n">sqft_missing_radio_choice</span><span class="p">,</span> <span class="n">sqft_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">sqft_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="p">((</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'YrBuilt'</span><span class="p">)[</span><span class="s">'YrBuilt'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">years_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">years_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">|</span> <span class="n">yrbuilt_radio_button</span><span class="p">(</span><span class="n">yrbuilt_missing_radio_choice</span><span class="p">,</span> <span class="n">years_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">years_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="p">((</span><span class="n">df</span><span class="p">.</span><span class="n">sort_values</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s">'ppsqft'</span><span class="p">)[</span><span class="s">'ppsqft'</span><span class="p">].</span><span class="n">between</span><span class="p">(</span><span class="n">ppsqft_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">ppsqft_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">|</span> <span class="n">ppsqft_radio_button</span><span class="p">(</span><span class="n">ppsqft_missing_radio_choice</span><span class="p">,</span> <span class="n">ppsqft_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">ppsqft_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&amp;</span>
    <span class="n">furnished_checklist_function</span><span class="p">(</span><span class="n">furnished_choice</span><span class="p">)</span> <span class="o">&amp;</span>
    <span class="n">security_deposit_function</span><span class="p">(</span><span class="n">security_deposit_radio_choice</span><span class="p">,</span> <span class="n">security_deposit_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">security_deposit_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">&amp;</span>
    <span class="n">pet_deposit_function</span><span class="p">(</span><span class="n">pet_deposit_radio_choice</span><span class="p">,</span> <span class="n">pet_deposit_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">pet_deposit_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">&amp;</span>
    <span class="n">key_deposit_function</span><span class="p">(</span><span class="n">key_deposit_radio_choice</span><span class="p">,</span> <span class="n">key_deposit_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">key_deposit_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">&amp;</span>
    <span class="n">other_deposit_function</span><span class="p">(</span><span class="n">other_deposit_radio_choice</span><span class="p">,</span> <span class="n">other_deposit_chosen</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">other_deposit_chosen</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">&amp;</span>
    <span class="n">listed_date_function</span><span class="p">(</span><span class="n">listed_date_radio</span><span class="p">,</span> <span class="n">listed_date_datepicker_start</span><span class="p">,</span> <span class="n">listed_date_datepicker_end</span><span class="p">)</span> <span class="o">&amp;</span>
    <span class="n">laundry_checklist_function</span><span class="p">(</span><span class="n">laundry_chosen</span><span class="p">)</span>
  <span class="p">]</span>
</code></pre></div></div>

<p>You can see here that the callback performs <a href="https://github.com/perfectly-preserved-pie/larentals/blob/master/pages/lease_page.py#L40-L198">various dataframe options (filtering, comparing strings, etc.)</a> and puts the results in a <code class="language-plaintext highlighter-rouge">df_filtered</code> variable. I then iterate through <code class="language-plaintext highlighter-rouge">df_filtered</code> to generate the markers &amp; their associated popups, as you’ll see below.</p>

<h3 id="mapping">Mapping</h3>
<p>Now that I had a list of coordinates, I needed a way to actually <em>display</em> the points on the map. This led me to <a href="http://python-visualization.github.io/folium/">Folium</a>, however I wasn’t too happy with the look and soon moved on to <a href="https://github.com/plotly/dash">Dash by Plotly</a>. Even then I still wasn’t satisified with any of the map types. Heatmaps, chloropeths, etc. all were too complex for what I wanted: a simple marker with a table of the property’s characteristics (rent price, garage spaces, address, etc.). My search led me to <a href="https://dash-leaflet.herokuapp.com/">Dash-Leaflet</a> which was perfect. Not only did it look good but nearby points could all be part of a cluster group that would expand and shrink as the user zoomed the map in or out:</p>

<p><a href="https://i.imgur.com/czbdxpQ.mp4">Example</a></p>

<p>I wanted each marker to show the property details, so I created a function to return HTML code for the marker’s popup:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Define HTML code for the popup so it looks pretty and nice
</span><span class="k">def</span> <span class="nf">popup_html</span><span class="p">(</span><span class="n">row</span><span class="p">):</span>
    <span class="n">i</span> <span class="o">=</span> <span class="n">row</span><span class="p">.</span><span class="n">Index</span>
    <span class="n">street_address</span><span class="o">=</span><span class="n">df</span><span class="p">[</span><span class="s">'Full Street Address'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> 
    <span class="n">mls_number</span><span class="o">=</span><span class="n">df</span><span class="p">[</span><span class="s">'Listing ID'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">mls_number_hyperlink</span><span class="o">=</span><span class="n">df</span><span class="p">[</span><span class="s">'bhhs_url'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">mls_photo</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'MLS Photo'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">lc_price</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'List Price'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> 
    <span class="n">price_per_sqft</span><span class="o">=</span><span class="n">df</span><span class="p">[</span><span class="s">'Price Per Square Foot'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>                  
    <span class="n">brba</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Br/Ba'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">square_ft</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Sqft'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">year</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'YrBuilt'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">garage</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Garage Spaces'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">pets</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'PetsAllowed'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">phone</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'List Office Phone'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">terms</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Terms'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">sub_type</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Sub Type'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">listed_date</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">to_datetime</span><span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">'Listed Date'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]).</span><span class="n">date</span><span class="p">()</span> <span class="c1"># Convert the full datetime into date only. See https://stackoverflow.com/a/47388569
</span>    <span class="n">furnished</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'Furnished'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">key_deposit</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'DepositKey'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">other_deposit</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'DepositOther'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">pet_deposit</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'DepositPets'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
    <span class="n">security_deposit</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">'DepositSecurity'</span><span class="p">].</span><span class="n">at</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</code></pre></div></div>

<p>That function uses the Pandas dataframe row to populate different fields like rental price, garage spaces, terms, etc. and formats them into an HTML table for that specific marker:
<img src="https://user-images.githubusercontent.com/28774550/188334715-20842be0-b171-4631-8c1b-cf908ee1715a.png" alt="image" /></p>

<p>Then, in my Dash callback, I iterate through the filtered dataframe <code class="language-plaintext highlighter-rouge">df_filtered</code> and add each row’s coordinates &amp; popup HTML to a list. Then I use Dash Leaflet’s <code class="language-plaintext highlighter-rouge">dicts_to_geojson</code> function to convert each object in the list a GeoJSON object that can be displayed on the map:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Create an empty list for the markers
</span>  <span class="n">markers</span> <span class="o">=</span> <span class="p">[]</span>
  <span class="c1"># Iterate through the dataframe, create a marker for each row, and append it to the list
</span>  <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">df_filtered</span><span class="p">.</span><span class="n">itertuples</span><span class="p">():</span>
    <span class="n">markers</span><span class="p">.</span><span class="n">append</span><span class="p">(</span>
      <span class="nb">dict</span><span class="p">(</span>
        <span class="n">lat</span><span class="o">=</span><span class="n">row</span><span class="p">.</span><span class="n">Latitude</span><span class="p">,</span>
        <span class="n">lon</span><span class="o">=</span><span class="n">row</span><span class="p">.</span><span class="n">Longitude</span><span class="p">,</span>
        <span class="n">popup</span><span class="o">=</span><span class="n">row</span><span class="p">.</span><span class="n">popup_html</span>
        <span class="p">)</span>
    <span class="p">)</span>
  <span class="c1"># Generate geojson with a marker for each listing
</span>  <span class="n">geojson</span> <span class="o">=</span> <span class="n">dlx</span><span class="p">.</span><span class="n">dicts_to_geojson</span><span class="p">([{</span><span class="o">**</span><span class="n">m</span><span class="p">}</span> <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">markers</span><span class="p">])</span>
  <span class="p">...</span>
  <span class="c1"># Generate the map
</span>  <span class="k">return</span> <span class="n">dl</span><span class="p">.</span><span class="n">GeoJSON</span><span class="p">(</span>
    <span class="nb">id</span><span class="o">=</span><span class="nb">str</span><span class="p">(</span><span class="n">uuid</span><span class="p">.</span><span class="n">uuid4</span><span class="p">()),</span>
    <span class="n">data</span><span class="o">=</span><span class="n">geojson</span><span class="p">,</span>
    <span class="n">cluster</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">zoomToBoundsOnClick</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">superClusterOptions</span><span class="o">=</span><span class="p">{</span> <span class="c1"># https://github.com/mapbox/supercluster#options
</span>      <span class="s">'radius'</span><span class="p">:</span> <span class="mi">160</span><span class="p">,</span>
      <span class="s">'minZoom'</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>So now, whenever a user clicks on a marker, a popup appears with that property’s details. Very handy to see what it’s like at a glance.</p>

<h3 id="web-scraping">Web Scraping</h3>
<p>Because knowing <em>when</em> a listing was posted is important (a listing from 9 months ago probably isn’t going to be available) I wanted to get the “listed” date. That also led me to finding an MLS photo associcated with the property, so I figured I’d scrape that too and insert that photo into the HTML popup for the property.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">## Webscraping Time
# Create a function to scrape the listing's Berkshire Hathaway Home Services (BHHS) page using BeautifulSoup 4 and extract some info
</span><span class="k">def</span> <span class="nf">webscrape_bhhs</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">row_index</span><span class="p">):</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
        <span class="n">soup</span> <span class="o">=</span> <span class="n">bs4</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">text</span><span class="p">,</span> <span class="s">'html.parser'</span><span class="p">)</span>
        <span class="c1"># First find the URL to the actual listing instead of just the search result page
</span>        <span class="k">try</span><span class="p">:</span>
          <span class="n">link</span> <span class="o">=</span> <span class="s">'https://www.bhhscalifornia.com'</span> <span class="o">+</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">'a'</span><span class="p">,</span> <span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="s">'class'</span> <span class="p">:</span> <span class="s">'btn cab waves-effect waves-light btn-details show-listing-details'</span><span class="p">})[</span><span class="s">'href'</span><span class="p">]</span>
          <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Successfully fetched listing URL for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">."</span><span class="p">)</span>
        <span class="k">except</span> <span class="nb">AttributeError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
          <span class="n">link</span> <span class="o">=</span> <span class="bp">None</span>
          <span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't fetch listing URL for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">. Passing on..."</span><span class="p">)</span>
          <span class="k">pass</span>
        <span class="c1"># If the URL is available, fetch the MLS photo and listed date
</span>        <span class="k">if</span> <span class="n">link</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
          <span class="c1"># Now find the MLS photo URL
</span>          <span class="c1"># https://stackoverflow.com/a/44293555
</span>          <span class="k">try</span><span class="p">:</span>
            <span class="n">photo</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">'a'</span><span class="p">,</span> <span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="s">'class'</span> <span class="p">:</span> <span class="s">'show-listing-details'</span><span class="p">}).</span><span class="n">contents</span><span class="p">[</span><span class="mi">1</span><span class="p">][</span><span class="s">'src'</span><span class="p">]</span>
            <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Successfully fetched MLS photo for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">."</span><span class="p">)</span>
          <span class="k">except</span> <span class="nb">AttributeError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">photo</span> <span class="o">=</span> <span class="bp">None</span>
            <span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't fetch MLS photo for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">. Passing on..."</span><span class="p">)</span>
            <span class="k">pass</span>
          <span class="c1"># For the list date, split the p class into strings and get the last element in the list
</span>          <span class="c1"># https://stackoverflow.com/a/64976919
</span>          <span class="k">try</span><span class="p">:</span>
            <span class="n">listed_date</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">'p'</span><span class="p">,</span> <span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="s">'class'</span> <span class="p">:</span> <span class="s">'summary-mlsnumber'</span><span class="p">}).</span><span class="n">text</span><span class="p">.</span><span class="n">split</span><span class="p">()[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
            <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Successfully fetched listed date for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">."</span><span class="p">)</span>
          <span class="k">except</span> <span class="nb">AttributeError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">listed_date</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">NaT</span>
            <span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't fetch listed date for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s">. Passing on..."</span><span class="p">)</span>
            <span class="k">pass</span>
        <span class="k">elif</span> <span class="n">link</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
          <span class="k">pass</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
      <span class="n">listed_date</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">NaT</span>
      <span class="n">photo</span> <span class="o">=</span> <span class="n">NaN</span>
      <span class="n">link</span> <span class="o">=</span> <span class="n">NaN</span>
      <span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't scrape BHHS page for </span><span class="si">{</span><span class="n">row_index</span><span class="si">}</span><span class="s"> because of </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">. Passing on..."</span><span class="p">)</span>
      <span class="k">pass</span>
    <span class="k">return</span> <span class="n">listed_date</span><span class="p">,</span> <span class="n">photo</span><span class="p">,</span> <span class="n">link</span> 
</code></pre></div></div>

<p>Additionally, I also created a function to scrape the BHHS listing to check if the listing was still active. This is not a 100% accurate proxy, since realtors aren’t required to post a listing on an MLS or at all, but it’s good enough:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Create a function to check for expired listings based on the presence of a string
</span><span class="k">def</span> <span class="nf">check_expired_listing</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">mls_number</span><span class="p">):</span>
  <span class="k">try</span><span class="p">:</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>
    <span class="n">soup</span> <span class="o">=</span> <span class="n">bs4</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">text</span><span class="p">,</span> <span class="s">'html.parser'</span><span class="p">)</span>
    <span class="c1"># Detect if the listing has expired. Remove \t, \n, etc. and strip whitespaces
</span>    <span class="k">try</span><span class="p">:</span>
      <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">'div'</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="s">'page-description'</span><span class="p">).</span><span class="n">text</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\r</span><span class="s">"</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\t</span><span class="s">"</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="n">strip</span><span class="p">()</span>
      <span class="k">return</span> <span class="bp">True</span>
    <span class="k">except</span> <span class="nb">AttributeError</span><span class="p">:</span>
      <span class="k">return</span> <span class="bp">False</span>
  <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="n">logger</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"Couldn't detect if the listing for </span><span class="si">{</span><span class="n">mls_number</span><span class="si">}</span><span class="s"> has expired because </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">."</span><span class="p">)</span>
    <span class="k">return</span> <span class="bp">False</span>
</code></pre></div></div>

<h2 id="challenges">Challenges</h2>
<p>At some point due to <a href="https://github.com/thedirtyfew/dash-leaflet/issues/168#issuecomment-1374404327">performance issues</a> with <code class="language-plaintext highlighter-rouge">dl.MarkerClusterGroup</code> I switched to using <code class="language-plaintext highlighter-rouge">dl.GeoJSON</code> to generate the markers. Unforunately, this change meant that whenever the map is panned or zoomed, even just a tiny bit, any open popup immediately closes. 
To make things worse, clicking on a marker automatically pans the map to fit the resulting popup, but the MLS photo loads <em>after</em> the popup and therefore the map pans <em>a second time</em>, closing the popup. You’d have to click a marker twice, sometimes even 3 or 4 times, to get the popup to stay. That leads to a frustrating UX (at least for me), because panning the map shouldn’t close the popup. It’s even worse on a mobile device (or any device with a small screen) because there’s so little screen real estate to begin with.</p>

<p>Basically, the problem is that with <code class="language-plaintext highlighter-rouge">cluster=True</code>, the clusters (and markers) are redrawn whenever the viewport changes (by panning or zooming). That’s what makes the popups close.</p>

<p>I brought up this issue on the official GitHub repo and happily, <a href="https://github.com/thedirtyfew/dash-leaflet/issues/180#issuecomment-1694490970">Emil has solved this with a new version of Dash Leaflet</a>. The popups stay open even when you pan or zoom the map, greatly reducing the UX friction. Thank you Emil!!</p>

<p>This was a long, long running bug in the project (<a href="https://github.com/perfectly-preserved-pie/larentals/pull/16">7 months!</a>). It’s finally been squashed and I couldn’t be more relieved because it has bugged me the entire damn time.</p>

<p>Anyways, I hope you get some use out of this website; it was a labor of love.</p>]]></content><author><name>Sahib Bhai</name></author><summary type="html"><![CDATA[WhereToLive.LA]]></summary></entry><entry><title type="html">Rejectedplates</title><link href="https://automateordie.dev/rejectedplates/" rel="alternate" type="text/html" title="Rejectedplates" /><published>2022-03-05T00:00:00-08:00</published><updated>2022-03-05T00:00:00-08:00</updated><id>https://automateordie.dev/rejectedplates</id><content type="html" xml:base="https://automateordie.dev/rejectedplates/"><![CDATA[<h1 id="rejected-plates">Rejected Plates</h1>
<p>A few weeks ago I saw <a href="https://www.reddit.com/r/cars/comments/siv6ik/in_florida_over_500_personalized_license_plate/">a post on the /r/cars subreddit</a> titled “<em>In Florida, Over 500 Personalized License Plate Requests Were Denied in 2021 - Here’s the List</em>”. It was a list of all the vanity plate requests that the Florida DMV had denied in 2021. I thought it was pretty funny and interesting too.</p>

<p>I wanted to get a similar list for other states and post it - but where? My mind instinctively went to the <a href="https://twitter.com/everyword?lang=en">@everyword Twitter bot</a>. Because there was very little additional context available regarding the license plates (just like the English words that @everyword tweeted) I figured I should post the rejected vanity plates in a similar fashion: a single string being Tweeted once an hour or so. I could <a href="https://github.com/perfectly-preserved-pie/rejectedplates">write a Python script</a> that would ingest the data via Pandas dataframe and iterate over it, Tweeting a single license plate at some set interval. Seemed easy enough…</p>

<h2 id="fun-with-foia">Fun With FOIA</h2>
<p>But how could I get this data for other states and different years? I read the original news article and found a mention that the news station had submitted a FOIA request. “The Freedom of Information Act (FOIA) is a federal law that gives the public the right to make requests for federal agency records.” There! I had a method to get data for other states now. After some more research on FOIA, I found a service that actually makes the requests for you, including following up, negotiating payments and data delivery methods, etc. It’s called <a href="https://www.muckrock.com/">MuckRock</a>. Any FOIA request made on that website is then made publicly available for free, so I searched for rejected vanity plate records and <a href="https://www.muckrock.com/foi/list/?csrfmiddlewaretoken=LK03Lo11SnN2japNrxaSeW31ymquwbe1YHlxvpQ4Z8aOTRrH10vMyHX6UfX73P2F&amp;q=license+plate+vanity&amp;status=done&amp;has_embargo=&amp;has_crowdfund=&amp;minimum_pages=&amp;date_range_min=&amp;date_range_max=&amp;file_types=">found about 11 of them</a>. Some of them even had the data readily available in .CSV form which I could easily ingest via Panda’s read_csv function.</p>

<p>Once I exhaust <a href="https://github.com/perfectly-preserved-pie/rejectedplates/tree/main/States">my current supply of data</a>, I intend on using MuckRock to file more FOIA requests.</p>

<h3 id="the-good-thing-about-standards-is-that-there-are-so-many-to-choose-from">“The good thing about standards is that there are so many to choose from.”</h3>
<p><img src="https://user-images.githubusercontent.com/28774550/156232090-b3d30300-4afb-43ee-9ad8-e1ba6cc03396.png" alt="image" /></p>

<p>Unfortunately, every state has their own method of delivering this data, so each completed FOIA request wasn’t exactly the same. Some files had different headers (or no headers at all), some had additional details like rejection reason while others didn’t, some were delivered in PDF form or .xls (old school Excel). It wasn’t hard but I did have to manually clean up the spreadsheets or export from PDF to CSV. You can see my results on <a href="https://github.com/perfectly-preserved-pie/rejectedplates/tree/main/States">the GitHub page for the project</a>.</p>

<h3 id="side-note-why-didnt-i-post-the-rejection-reason-along-with-the-plate">Side Note: Why Didn’t I Post The Rejection Reason Along With The Plate?</h3>
<p>I elected to only post the plate itself simply because the rejection reason wasn’t available consistently. While I could ask for it, going through the Muckrock documents I realized that a state may not have this data readily available and could possibly charge (more) money to complete the request. Some explanations given by state representatives:</p>
<ul>
  <li>The reason is never logged/entered by policy</li>
  <li>The data is logged, but is in a different system and therefore requires manual labor to associate with each plate configuration</li>
  <li>The data is all pen-and-paper and nothing has been digitized (sounds about right for the DMV…). Therefore, manual labor and the associated costs are required.</li>
</ul>

<p>So it made more sense to just post the plate configuration only, as that’s the only thing that would be consistently available. The reason, the date, etc. might not be.</p>

<h2 id="new-coding-concepts">New Coding Concepts</h2>
<h3 id="using-the-twitter-api-via-tweepy">Using the Twitter API via Tweepy</h3>
<p>The script itself is pretty simple. I’m again using Pandas to iterate over a dataframe. One problem I didn’t forsee was how to keep track of what’s been posted and what hasn’t. I originally had thought of just marking a dataframe column entry as “done” whenever a Tweet was posted; however the dataframe lives in memory and these tracking changes would get wiped if the script or VM ever crashed. So I needed to either:</p>
<ol>
  <li>Write to a file as a sort of log</li>
  <li>Use Twitter’s API to search for what’s already been posted</li>
</ol>

<p>I elected to use the latter method <a href="https://docs.tweepy.org/en/stable/client.html#tweepy.Client.get_users_tweets">using the Tweepy Python module</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">plate</span> <span class="ow">in</span> <span class="n">df</span><span class="p">.</span><span class="n">itertuples</span><span class="p">():</span>
	<span class="k">try</span><span class="p">:</span>
		<span class="c1"># Get the most recent 10 tweets
</span>		<span class="n">tweets</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">get_users_tweets</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">twitter_id</span><span class="p">,</span><span class="n">user_auth</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="p">...</span>
  <span class="c1"># Create an empty list 
</span>	<span class="n">tweets_list</span> <span class="o">=</span> <span class="p">[]</span>
	<span class="c1"># Iterate over the tweets and add the tweet text to the empty list we just created
</span>	<span class="k">for</span> <span class="n">tweet</span> <span class="ow">in</span> <span class="n">tweets</span><span class="p">.</span><span class="n">data</span><span class="p">:</span>
		<span class="n">tweets_list</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">tweet</span><span class="p">.</span><span class="n">text</span><span class="p">)</span>
	<span class="c1"># Iterate over the new list. If the license plate we're about to post doesn't already exist, post it to Twitter
</span>	<span class="k">if</span> <span class="n">plate</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">tweets_list</span><span class="p">:</span>
		<span class="k">try</span><span class="p">:</span>
			<span class="n">client</span><span class="p">.</span><span class="n">create_tweet</span><span class="p">(</span><span class="n">text</span><span class="o">=</span><span class="n">plate</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span><span class="n">place_id</span><span class="o">=</span><span class="n">place_id</span><span class="p">)</span>
			<span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">plate</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s"> has been tweeted."</span><span class="p">)</span>
			<span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1800</span><span class="p">)</span> 
</code></pre></div></div>
<p>Admittedly, this isn’t very efficient or fault-tolerant.</p>

<h4 id="the-looming-threat">The Looming Threat</h4>
<p>Say that down the line, once the account has 2,000+ tweets for example, the script crashes and I restart it. It would iterate through the dataframe from the beginning and for plate #1, send an API call for the last 10 tweets. But since plate #1 has already been tweeted days/weeks/months ago and there’s already 2000+ tweets, plate #1 wouldn’t be found in the last 10 tweets and therefore technically not exist. The script would then re-post plate #1 which isn’t what I want (no duplicates!)</p>

<p>According to Twitter’s API docs, only the most 3200 recent tweets can be grabbed per one request via pagination. The first dataset alone (Maryland 2013) has approx. 4000 entries so I would need to use pagination and check that list for the plate entry instead of just looking through the last 10 tweets. Unfortunately that’s something I didn’t code for but I’m definitely looking into using pagination for subsequent datasets. For now, I’ll take the risk that the Maryland 2013 dataset will complete without any crashing or hiccups.</p>

<h3 id="logging--alerting">Logging &amp; Alerting</h3>
<p>This time around I wanted to make sure all progress/warnings/errors would be logged to a file. In addition, I also wanted to be alerted about any warnings or errors via Telegram.</p>

<h4 id="logging">Logging</h4>

<p>Logging to a file was simple enough thanks to <a href="https://docs.python.org/3/howto/logging.html#logging-to-a-file">Python’s native logging module</a>:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Log to a file
</span><span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span>
	<span class="n">filename</span><span class="o">=</span><span class="s">'rejectedplates.log'</span><span class="p">,</span>
	<span class="n">encoding</span><span class="o">=</span><span class="s">'utf-8'</span><span class="p">,</span>
	<span class="nb">format</span><span class="o">=</span><span class="s">"%(asctime)s - %(name)s - %(levelname)s - %(message)s"</span><span class="p">,</span>
	<span class="n">datefmt</span><span class="o">=</span><span class="s">'%Y-%m-%d %I:%M:%S %p'</span><span class="p">,</span>
	<span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">INFO</span><span class="p">)</span>
</code></pre></div></div>

<p>Doing a <code class="language-plaintext highlighter-rouge">tail -f rejectedplates.log</code> gets me a nice autoscrolling output about what’s going on:</p>
<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2022-03-02 06:43:05 AM - root - INFO - 11MFA0 has been tweeted.
2022-03-02 07:13:06 AM - root - INFO - 11MFAO has been tweeted.
2022-03-02 07:43:06 AM - root - INFO - 11UV1R6 has been tweeted.
2022-03-02 08:13:07 AM - root - INFO - 11UVIR6 has been tweeted.
</code></pre></div></div>

<h4 id="alerting">Alerting</h4>
<p>But parsing through a log file isn’t enough. I wanted to be notified immediately if there was any warning or error. I don’t really need to be alerted any time there’s a success, so no INFO or DEBUG-type messages would be needed. I opted to use a Telegram bot to notify me as that’s what I’ve done for a few other homelab projects.</p>

<p>There are multiple Python Telegram modules available but since I didn’t need anything super complicated or powerful I opted to use Telethon as that seemed the easiest to set up and get running. I <a href="https://docs.telethon.dev/en/stable/basic/signing-in.html#signing-in">created a Telegram app</a> and added some code to sign in as a bot:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Set up Telegram API stuff
# https://my.telegram.org, under API Development.
# https://docs.telethon.dev/en/stable/basic/signing-in.html#signing-in-as-a-bot-account
</span><span class="n">telegram_username</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">'telegram_username'</span><span class="p">)</span>
<span class="n">api_id</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">'telegram_api_id'</span><span class="p">)</span>
<span class="n">api_hash</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">'telegram_api_hash'</span><span class="p">)</span>
<span class="n">bot_token</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">'telegram_bot_token'</span><span class="p">)</span>
<span class="n">bot</span> <span class="o">=</span> <span class="n">TelegramClient</span><span class="p">(</span><span class="s">'bot'</span><span class="p">,</span> <span class="n">api_id</span><span class="p">,</span> <span class="n">api_hash</span><span class="p">).</span><span class="n">start</span><span class="p">(</span><span class="n">bot_token</span><span class="o">=</span><span class="n">bot_token</span><span class="p">)</span>
</code></pre></div></div>

<p>And then added a few try/except blocks to send any exceptions to the Telegram bot which would alert me. For example, if there was an error retrieving the last tweets from the Twitter API:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">except</span> <span class="n">tweepy</span><span class="p">.</span><span class="n">TweepError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
		<span class="n">timeline_error_msg</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"Couldn't get the last 10 tweets because </span><span class="si">{</span><span class="n">e</span><span class="p">.</span><span class="n">reason</span><span class="si">}</span><span class="s">"</span>
		<span class="n">logging</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="n">timeline_error_msg</span><span class="p">)</span>
		<span class="n">bot</span><span class="p">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">telegram_username</span><span class="p">,</span> <span class="n">timeline_error_msg</span><span class="p">)</span> <span class="c1"># send a Telegram message
</span>		<span class="k">continue</span> <span class="c1"># Skip this iteration of the for loop and continue to the next one
</span></code></pre></div></div>

<p>I both log it to a file and send it via Telegram.</p>

<p>Or if a plate configuration had already been tweeted:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">elif</span> <span class="n">plate</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="ow">in</span> <span class="n">tweets_list</span><span class="p">:</span>
		<span class="n">post_warning_msg</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">plate</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s"> was already tweeted, skipping..."</span>
		<span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="n">post_warning_msg</span><span class="p">)</span>
		<span class="n">bot</span><span class="p">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">telegram_username</span><span class="p">,</span> <span class="n">post_warning_msg</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="whats-next-for-rejectedplates">What’s Next For @rejectedplates?</h2>
<p>I definitely need to work on the <a href="https://automateordie.io/rejectedplates/#the-looming-threat">Tweet search/pagination issue I mentioned above</a>. There’s also no doubt in my mind that I’ll need to rewrite some of the CSV ingestion lines to handle the differing formats of each CSV dataset. I plan on continuing this bot for as long as I can get fresh data for it. Theoretically, I have 50 states per year for 8 years to get through so I should be busy for quite a while…</p>]]></content><author><name>Sahib Bhai</name></author><summary type="html"><![CDATA[Rejected Plates A few weeks ago I saw a post on the /r/cars subreddit titled “In Florida, Over 500 Personalized License Plate Requests Were Denied in 2021 - Here’s the List”. It was a list of all the vanity plate requests that the Florida DMV had denied in 2021. I thought it was pretty funny and interesting too.]]></summary></entry><entry><title type="html">Lastwords</title><link href="https://automateordie.dev/lastwords/" rel="alternate" type="text/html" title="Lastwords" /><published>2022-01-12T00:00:00-08:00</published><updated>2022-01-12T00:00:00-08:00</updated><id>https://automateordie.dev/lastwords</id><content type="html" xml:base="https://automateordie.dev/lastwords/"><![CDATA[<h1 id="the-last-words-project">The Last Words Project</h1>

<h2 id="the-website">The Website</h2>
<p><a href="https://lastwords.fyi">I made a website for my new project!</a> It’s called Last Words.</p>

<h2 id="the-idea">The Idea</h2>
<p>Back in 2014 or so, I found out that the Texas Department of Corrective Justice posts every single executed inmate’s last statement <a href="https://www.tdcj.texas.gov/death_row/dr_executed_offenders.html">on their website</a>. While definitely morbidly interesting, I also thought it would make a great coding project: what if I could automatically scrape each last statement along with the inmate’s details and post them to a website? Furthermore, what if the website was <em>actually pretty</em>? I got my design inspiration from another Tumblr blog I had been following at the time, <a href="https://www.aseaofquotes.com/">A Sea of Quotes</a>. I wanted to apply that same look to these last statements.</p>

<h2 id="the-problems">The Problem(s)</h2>
<p>Well, it turns out that</p>
<ol>
  <li>Coding is fucking hard.</li>
  <li>Web design is fucking hard.</li>
</ol>

<p>And for the next 8 years or so, I floundered. I would get a burst of inspiration, work on the project, run into some coding obstacle, and then give up.</p>

<p><img src="https://i.imgur.com/qE2xj7x.jpg" alt="Rinse and repeat for years and years" /></p>

<p>But it was always in the back of my mind, nagging me. If I could just get the code right, I could do it.</p>

<h3 id="until-now">Until now…</h3>
<p>I’m not sure what changed, but I think it was a combination of boredom, winter break, being sick and isolated, and my decent-enough PowerShell skills. That gave me enough confidence (and dare I say, the skill?!) to start the project again and this time <strong>stick with it</strong>.</p>

<h2 id="the-stack">The Stack</h2>
<p>My original intent was to use a combination of an AWS LightSail WordPress instance (for the website) and PowerShell (to scrape the TDCJ webpage). However, I didn’t like many of the WordPress themes and found the whole concept just <em>too overwhelming</em>. So many options, themes, addons, blocks, etc. I wanted to keep it simple.
Additionally, I found that webscraping with PowerShell is pretty difficult; there weren’t many HTML modules (unless you want to get into .NET) I could use. I’d like to think I’m pretty good with PS but this project was just so far out of my wheelhouse I was at a loss.</p>

<p>So I decided to use Python, which meant learning it from scratch. That was tough; coming from PowerShell, it was terribly confusing and foreign. But I had heard about modules like BeautifulSoup and Pandas which seemed perfect for my use case. Through a lot of copy and pasting from Reddit &amp; StackOverflow (as is tradition), I managed to get a working proof-of-concept which gave me the hope and motivation I needed to continue on.</p>

<p>For the website, I tried out Wix and SquareSpace, but again was overwhelmed by so many options. Also, I couldn’t see how to create a layout that was optimized for quotes only instead of photos or long blog posts. So I went back to my source of inspiration, A Sea of Quotes. And then it hit me - why not just use Tumblr? I already had an excellent example of a quote post theme done right. Plus, Tumblr had a pretty good API with <a href="https://github.com/tumblr/pytumblr">a Python client available</a>.</p>

<h2 id="the-theme">The Theme</h2>
<p>Initially I started out with <a href="https://www.tumblr.com/theme/8631">Notations</a> but thought it looked a little outdated. I wasn’t satisified and kept looking.
After some further Googling, I stumbled upon <a href="https://shoseii.tumblr.com/post/174988950529/zephyr-theme-14-live-preview-codes">Zephyr</a>. The multi-column layout and infinite scrolling features were especially attractive. And the quote posts looked pretty clean as well.</p>

<h2 id="the-work">The Work</h2>
<p>I had my work cut out for me. While <a href="https://stackoverflow.com/a/64873079">the base of the code had already been written for me</a> (thank you, internet!), I still needed to do a number of additional things to make it so it would “fit” my new Tumblr blog. These things included:</p>
<ul>
  <li>Using BeautifulSoup to grab specific links in an HTML table based on their tag position (in this case, the second “a href” tag)
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c1"># Use bs4 to get the Offender Information URLs
</span> <span class="n">offender_information_urls</span> <span class="o">=</span> <span class="p">[]</span>
 <span class="c1"># Select the correct &lt;a href&gt; tag based on second tag I guess
</span> <span class="k">for</span> <span class="n">link</span> <span class="ow">in</span> <span class="n">soup</span><span class="p">.</span><span class="n">select</span><span class="p">(</span><span class="s">'tr&gt;td:nth-child(2)&gt;a'</span><span class="p">):</span>
     <span class="n">offender_information_urls</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">base_url</span><span class="si">}</span><span class="s">/"</span> <span class="o">+</span> <span class="n">link</span><span class="p">[</span><span class="s">'href'</span><span class="p">])</span>
 <span class="c1"># Add the URLs to the dataframe
</span> <span class="n">df</span><span class="p">[</span><span class="s">"Offender Information"</span><span class="p">]</span> <span class="o">=</span> <span class="n">offender_information_urls</span>
</code></pre></div>    </div>
  </li>
  <li>Removing all inmates that didn’t have a last statement or declined to say one
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Remove all inmates that don't have a last statement
# We'll first create a list of keywords indicating no last statement
# https://stackoverflow.com/a/43399866
</span><span class="n">keywords</span> <span class="o">=</span> <span class="p">[</span><span class="s">'This inmate declined to make a last statement.'</span><span class="p">,</span><span class="s">'No statement was made.'</span><span class="p">,</span><span class="s">'No statement given.'</span><span class="p">,</span><span class="s">'None'</span><span class="p">,</span><span class="s">'None.'</span><span class="p">,</span><span class="s">'None '</span><span class="p">,</span><span class="s">'(Written statement)'</span><span class="p">,</span><span class="s">'Spoken: No'</span><span class="p">,</span><span class="s">'Spoken: No.'</span><span class="p">,</span><span class="s">'No'</span><span class="p">,</span><span class="s">'No last statement.'</span><span class="p">,</span><span class="s">'No, I have no final statement.'</span><span class="p">,</span> <span class="s">''</span><span class="p">]</span>
<span class="n">empty_statements</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">[</span><span class="s">'Last Statement'</span><span class="p">].</span><span class="n">isin</span><span class="p">(</span><span class="n">keywords</span><span class="p">)].</span><span class="n">Execution</span><span class="p">.</span><span class="n">count</span><span class="p">()</span> <span class="o">+</span> <span class="n">df</span><span class="p">[</span><span class="s">'Last Statement'</span><span class="p">].</span><span class="n">isnull</span><span class="p">().</span><span class="nb">sum</span><span class="p">().</span><span class="nb">sum</span><span class="p">()</span>
<span class="c1"># Drop all rows containing these "no last statement" keywords
</span><span class="n">df</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="o">~</span><span class="n">df</span><span class="p">[</span><span class="s">'Last Statement'</span><span class="p">].</span><span class="n">isin</span><span class="p">(</span><span class="n">keywords</span><span class="p">)]</span>
<span class="c1"># Now we drop all rows containing NaN
# https://hackersandslackers.com/pandas-dataframe-drop/
</span><span class="n">df</span><span class="p">.</span><span class="n">dropna</span><span class="p">(</span><span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span><span class="n">how</span><span class="o">=</span><span class="s">'any'</span><span class="p">,</span><span class="n">subset</span><span class="o">=</span><span class="p">[</span><span class="s">'Last Statement'</span><span class="p">],</span><span class="n">inplace</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div>    </div>
  </li>
  <li>Working around Tumblr’s API limits</li>
  <li>Iterating over batches of dataframes</li>
  <li>Using matplotlib to generate simple statistical plots of the data
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c1"># Age distribution
# https://riptutorial.com/pandas/example/5965/grouping-numbers
</span><span class="n">age_groups</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">cut</span><span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">Age</span><span class="p">,</span> <span class="n">bins</span><span class="o">=</span><span class="p">[</span><span class="mi">18</span><span class="p">,</span> <span class="mi">20</span><span class="p">,</span> <span class="mi">29</span><span class="p">,</span> <span class="mi">39</span><span class="p">,</span> <span class="mi">49</span><span class="p">,</span> <span class="mi">59</span><span class="p">,</span> <span class="mi">69</span><span class="p">,</span> <span class="mi">79</span><span class="p">,</span> <span class="mi">89</span><span class="p">],</span> <span class="n">labels</span><span class="o">=</span><span class="p">[</span><span class="s">'18 to 20 years old'</span><span class="p">,</span> <span class="s">'21 to 29 years old'</span><span class="p">,</span> <span class="s">'30 to 39 years old'</span><span class="p">,</span> <span class="s">'40 to 49 years old'</span><span class="p">,</span> <span class="s">'50 to 59 years old'</span><span class="p">,</span> <span class="s">'60 to 69 years old'</span><span class="p">,</span> <span class="s">'70 to 79 years old'</span><span class="p">,</span> <span class="s">'80 to 89 years old'</span><span class="p">])</span>
<span class="c1"># Plot the groups
# https://stackoverflow.com/a/40314011
</span><span class="n">age_groups_count</span> <span class="o">=</span> <span class="n">df</span><span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="n">age_groups</span><span class="p">)[</span><span class="s">'Age'</span><span class="p">].</span><span class="n">count</span><span class="p">()</span>
<span class="n">age_plot</span> <span class="o">=</span> <span class="n">age_groups_count</span><span class="p">.</span><span class="n">plot</span><span class="p">(</span><span class="n">kind</span><span class="o">=</span><span class="s">'bar'</span><span class="p">,</span> <span class="n">title</span><span class="o">=</span><span class="s">'Age Distribution of Executed Inmates in Texas, 1982-2021'</span><span class="p">,</span> <span class="n">ylabel</span><span class="o">=</span><span class="s">'Number of Inmates'</span><span class="p">,</span> <span class="n">xlabel</span><span class="o">=</span><span class="s">'Age Group'</span><span class="p">)</span>
<span class="c1"># Annotate the bars
# https://stackoverflow.com/a/67561982
</span><span class="n">age_plot</span><span class="p">.</span><span class="n">bar_label</span><span class="p">(</span><span class="n">age_plot</span><span class="p">.</span><span class="n">containers</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">label_type</span><span class="o">=</span><span class="s">'edge'</span><span class="p">)</span>
<span class="c1"># Save the plot as a PNG
</span><span class="k">print</span><span class="p">(</span><span class="s">"Saving the plotted age graph..."</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">savefig</span><span class="p">(</span><span class="s">"/tmp/age_distribution.png"</span><span class="p">,</span> <span class="n">bbox_inches</span> <span class="o">=</span> <span class="s">'tight'</span><span class="p">)</span>
<span class="c1"># Close the figure window to prevent the next graph from using the same values
# https://stackoverflow.com/a/8228808
</span><span class="n">plt</span><span class="p">.</span><span class="n">clf</span><span class="p">()</span>
</code></pre></div>    </div>
  </li>
  <li>Using SymPy to <em>solve an algebraic equation</em> (woah! Haven’t done that in a while!)
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Algebra comes in handy here. My high school math teacher has finally been vindicated 15 years later 👏
# It doesn't matter how many rows we have: we need to post x posts until we remain with 300 posts
# The last 300 posts will be queued
# Use SymPy to solve an algebraic equation
# https://scipy-lectures.org/packages/sympy.html#equation-solving
</span><span class="n">x</span> <span class="o">=</span> <span class="n">Symbol</span><span class="p">(</span><span class="s">'x'</span><span class="p">)</span>
<span class="c1"># Solve for x: how many posts do we need to immediately publish?
</span><span class="n">posts_to_publish</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">solve</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">index</span><span class="p">)</span><span class="o">-</span><span class="mi">300</span><span class="o">-</span><span class="n">x</span><span class="p">,</span> <span class="n">x</span><span class="p">)[</span><span class="mi">0</span><span class="p">])</span>
</code></pre></div>    </div>
  </li>
</ul>

<h2 id="the-journey">The Journey</h2>
<p>I’ve learned a ton about Pandas, dataframes, and Python in general. Even though the base of my code was simply copied &amp; pasted, the work I did have to do was enough to send me on a Python journey and as a result I feel a little more confident in my coding skills. I have a much better handle on the various Python modules and I’m honestly in awe of the whole Python module ecosystem. It’s amazing. There’s a module for damn near everything. Pandas, Numpy, and BeautifulSoup have been an incredible help here.</p>

<p>Also, web scraping is <em>tough</em>. There’s almost no coordination or standards from one page to the next; I spent so much time trying to account for every little deviation I was almost driven mad.</p>

<p><img src="https://imgs.xkcd.com/comics/data_pipeline.png" alt="So many edge cases..." /></p>

<p>With all that being said, it was fun! And incredibly frustrating at times! 🥳</p>

<h2 id="the-code">The Code</h2>
<p><a href="https://github.com/perfectly-preserved-pie/lastwords">I’ve put up my code on my GitHub</a>. Feel free to flame me for my awful spaghetti code. I won’t lie: I definitely coded with the intention of just making it work rather than making it work <em>well</em>.</p>

<p>I can finally close the 122 tabs I have open related to this project. Jesus.</p>]]></content><author><name>Sahib Bhai</name></author><summary type="html"><![CDATA[The Last Words Project]]></summary></entry><entry><title type="html">Jamf Title Editor</title><link href="https://automateordie.dev/jamf-title-editor/" rel="alternate" type="text/html" title="Jamf Title Editor" /><published>2021-08-05T00:00:00-07:00</published><updated>2021-08-05T00:00:00-07:00</updated><id>https://automateordie.dev/jamf-title-editor</id><content type="html" xml:base="https://automateordie.dev/jamf-title-editor/"><![CDATA[<h1 id="introducing-jamf-title-editor">Introducing… JAMF Title Editor!</h1>

<p>Remember <a href="https://automateordie.io/using-kinobi-open-source-to-patch-custom-lob-applications/">my last post about patching 3rd party applications with Kinobi</a>? Well, JAMF has implemented that natively into the JAMF Pro interface <a href="https://docs.jamf.com/10.31.0/jamf-pro/release-notes/New_Features_and_Enhancements.html#concept-1673">as of version 10.31.0</a> (released 2021-07-29). So now we don’t even need Kinobi!</p>

<h3 id="the-good-news-">The good news 🎉</h3>
<p>The interface and terminology more or less resemble Kinobi. If you’ve been creating your own patch definitions with Kinobi, you’ll have no problem moving to JAMF. The process is almost the exact same.
You no longer need a separate Kinobi server either!</p>

<h3 id="the-bad-news-">The bad news 😫</h3>
<p>There’s no way to export your patch titles/definitions from Kinobi Open-Source so you’ll have to recreate them from scratch in JAMF. It should be a little easier this time since you already have the right data &amp; values, but I won’t lie - it’s gonna be tedious, especically if you have a lot of titles.
However, if you have your definitions saved as JSON files, JAMF’s Title Editor has an “Import JSON” button for you to use. Lucky you!</p>

<h4 id="alright-already-just-show-me-how-to-use-this-new-feature">Alright already, just show me how to use this new feature!</h4>
<p><a href="https://docs.jamf.com/title-editor/documentation/Setting_Up_Title_Editor_in_Jamf_Pro.html">Read up on JAMF’s excellent documentation</a>.</p>

<h4 id="final-thoughts">Final thoughts…</h4>
<p>Of course once I spend days setting up Kinobi for our company and then <em>more</em> days writing up a blog post on how to use it, JAMF decides to integrate the product into their solution and effectively neuter any reason for using Kinobi Open-Source. I’m not really complaining though - it’s a totally logical move and I’m happy to have one less VM I have to keep updated. Plus, there was no guarantee that Mondada would ensure Kinobi Open-Source would work with future versions of JAMF. At least with this move, I <em>know</em> my custom title definitions will always work.</p>]]></content><author><name>Sahib Bhai</name></author><summary type="html"><![CDATA[Introducing… JAMF Title Editor!]]></summary></entry><entry><title type="html">Using Kinobi Open Source To Patch Custom Lob Applications</title><link href="https://automateordie.dev/using-kinobi-open-source-to-patch-custom-lob-applications/" rel="alternate" type="text/html" title="Using Kinobi Open Source To Patch Custom Lob Applications" /><published>2021-06-14T00:00:00-07:00</published><updated>2021-06-14T00:00:00-07:00</updated><id>https://automateordie.dev/using-kinobi-open-source-to-patch-custom-lob-applications</id><content type="html" xml:base="https://automateordie.dev/using-kinobi-open-source-to-patch-custom-lob-applications/"><![CDATA[<h1 id="context">Context</h1>
<p>We use JAMF at our company but had a problem with keeping our line-of-business (LOB) applications updated. <a href="https://docs.jamf.com/jamf-app-catalog/Patch_Management_Software_Titles.html">JAMF’s Patch Management catalog</a>, while extensive, doesn’t include applications hiding behind a vendor’s paywall: VPN clients, NAC agents, AV/EDR agents, etc. 
In our case, one of those applications is Palo Alto GlobalProtect. While the application has its own auto-upgrade mechanism, I’m using it here as an example of what you can do with Kinobi Open-Source.</p>

<h1 id="external-patch-sources">External patch sources</h1>
<p>Aside from JAMF’s internal catalog (which is free), JAMF also allows you to configure an external patch source, which is really just a <a href="https://www.jamf.com/jamf-nation/articles/497/jamf-pro-external-patch-source-endpoints">webserver that can respond to specific API requests</a>. That JAMF article explains the API and JSON structure needed for a functioning external patch source. An external patch source will allow you to create a software title, definitons, etc. for <em>any</em> application and therefore let you update <em>any</em> application; you’re not just limited to whatever’s in the JAMF catalog. The downside is that <em>you</em> have to define the application, its requirements, its versions, etc. (and keep them updated!).
To get a hint of what you <em>can</em> (but sometimes don’t need) to define, take a look at <a href="https://mondada.atlassian.net/wiki/spaces/MSD/pages/553189450/Patch+Definitions">Kinobi’s page on the topic</a>.</p>

<h1 id="introducing-kinobi-open-source-not-self-hosted-not-cloud">Introducing Kinobi Open-Source (not self-hosted, not cloud)</h1>
<p>That JAMF article is pretty intimidating if you’ve never worked with APIs or JSON in general. Thankfully, we can stand on the shoulder of giants and use the solutions created by people who have already done the impressive work before us. One of these solutions is Kinobi Open-Source.
Kinobi is an external patch definition server that comes in 3 different flavors:</p>
<ul>
  <li>Kinobi Cloud, a cloud-hosted patch definition server for Jamf Pro with access to a Kinobi subscription and JSON importer.</li>
  <li>Kinobi Self-Hosted, a self-hosted patch definition server for Jamf Pro with access to a Kinobi subscription and JSON importer.</li>
  <li>Kinobi Open-Source, an open-source patch definition server for Jamf Pro.</li>
</ul>

<p>The first two (Cloud and Self-Hosted) require a paid subscription to use. The last one, Open-Source, is truly free and is what we’ll be using here.</p>

<h1 id="installing-kinobi-open-source">Installing Kinobi Open-Source</h1>
<h2 id="system-requirements">System Requirements</h2>
<p>Installation is pretty simple; the installer is a .run script that can run on</p>
<ul>
  <li>Ubuntu LTS Server 14.04 or later (18.04 recommended)</li>
  <li>Red Hat Enterprise Linux (RHEL) 6.4 or later</li>
  <li>CentOS 6.4 or later</li>
</ul>

<p>See the full system requirements <a href="https://github.com/mondada/kinobi#standalone">here</a>.</p>

<h2 id="actually-installing-it">Actually installing it</h2>
<p><a href="https://mondada.atlassian.net/wiki/spaces/MSD/pages/592216069/Kinobi+Open-Source">There’s already an installation guide provided by Mondada so I’ll just link it here.</a></p>

<h3 id="dont-install-kinobi-on-the-same-server-hosting-your-jamf-pro-instance-if-youre-not-using-jamf-cloud">Don’t install Kinobi on the same server hosting your JAMF Pro instance! (if you’re not using JAMF Cloud)</h3>
<p>If you install Kinobi on the JAMF Pro server, Kinobi won’t start; it uses port 443 which is already in use on a JAMF Pro server so the port bind will fail. You can check the status of Kinobi’s Apache webserver with <code class="language-plaintext highlighter-rouge">sudo systemctl status apache2.service</code>.
You’ll see an error message similiar to “Bind: Address Already in Use”.</p>

<p>For that reason, it’s best to spin up a new VM/server and install only Kinobi on that.</p>

<h1 id="how-to-manually-add-a-software-title">How to manually add a software title</h1>
<p>And now we get to the meat of it 🍖
Mondada has <a href="https://mondada.atlassian.net/wiki/spaces/MSD/pages/553222153/Manual+Creation">an excellent guide</a> on how to create your first software title.
In my case, I’ll be creating one for Palo Alto GlobalProtect.</p>

<h2 id="globalprotect-example">GlobalProtect example</h2>
<p>Click on the “New” button to start the process, then fill out the information specified.</p>
<ul>
  <li><strong>Name</strong>: this shows up in the JAMF GUI.</li>
  <li><strong>Publisher</strong>: this shows up in the JAMF GUI.</li>
  <li><strong>Application Name</strong>: optional. Skip it.</li>
  <li><strong>Bundle Identifier</strong>: optional. Skip it.</li>
  <li><strong>Current Version</strong>: What’s the latest version of this software you have in your environment? Put that here. It’ll be used in the patch reports as the latest version.</li>
  <li><strong>ID</strong>: This is just an internal reference for Kinobi. You can make it anything you want but I tend to just put the application name.
<img src="https://i.imgur.com/1u6dsQy.png" alt="Starting the process with some basic information" /></li>
</ul>

<p>When you’re finished, click Save. You’ve just created your first software title! 🎉 Now we need to add a “requirement”.</p>

<h3 id="whats-a-requirement-for-a-software-title">What’s a Requirement (for a software title)?</h3>
<p>Simply put, a requirement (per Kinobi) is “<em>Criteria used to determine which computers in your environment have this software title installed.</em>”
The syntax, form, and structure is exactly the same as a smart group or advanced search in JAMF. Ever created one of those? Maybe you’ve created a smart group or advanced search based on things like</p>
<ul>
  <li>ARM or x86_64 CPU architecture</li>
  <li>Presence of an installed application</li>
  <li>Operating System Version</li>
  <li>Extension Attribute value</li>
  <li>etc.</li>
</ul>

<p>It’s the same process for our software title requirement. You can make the requirement any of a number of different criteria but in general, <em>your requirement should be the easiest way to detect the presence of the application on a computer</em>; that’ll usually be “Application Title”.</p>
<ol>
  <li>Click the “Requirements” tab.</li>
  <li>Click the “Add” button to begin creating a requirement.</li>
  <li>
    <p>Click the drop-down menu and select “Application Title” and click Save.
<img src="https://i.imgur.com/mlJ5g8g.png" alt="Requirements" /></p>
  </li>
  <li>Then change the <strong>operator</strong> to “is” and the <strong>value</strong> to “GlobalProtect.app”
<img src="https://i.imgur.com/XOWuSvI.png" alt="Criteria" /></li>
</ol>

<h3 id="patchespatch-definitions">Patches/Patch Definitions</h3>
<p>Now that we’ve created the base software title and its requirement, we need to create some “sub-items” that Kinobi calls “patches” and JAMF calls “patch definitions”. Because we’re working with Kinobi, I’ll refer to them as patches from now on. Both terms refer to the same thing: software title version information. For example, each of these GlobalProtect versions would be its own patch (and each patch can have its own possibly different requirements):</p>
<ul>
  <li>GlobalProtect v5.0.8</li>
  <li>GlobalProtect v5.1.5</li>
  <li>GlobalProtect v5.2.6</li>
  <li>and so on.</li>
</ul>

<h4 id="creating-a-patch-in-kinobi">Creating a patch in Kinobi</h4>
<ol>
  <li>Click the “Patches” tab (next to the “Requirements” tab).</li>
  <li>Click the “New” button.</li>
  <li>Fill out the fields as shown:
    <ul>
      <li><strong>Sort Order</strong>: how should this appear in Kinobi. Purely cosmetic.</li>
      <li><strong>Version</strong>: Version associated with this patch.</li>
      <li><strong>Release Date</strong>: optional. You can set this to the actual release date in your environment, the vendor’s release date, or just leave it as default.</li>
      <li><strong>Standalone</strong>: if this patch will need to be installed incrementally, select No.</li>
      <li><strong>Reboot</strong>: Does the application patch need a reboot to complete the process?</li>
      <li><strong>Minimum Operating System</strong>: Self-explanatory.</li>
    </ul>
  </li>
</ol>

<p><img src="https://i.imgur.com/dlL6VKt.png" alt="New patch version" /></p>

<h2 id="-a-patch-for-each-app-version-is-needed-otherwise-they-all-show-up-as-unknown">⚠ A patch for <strong>each</strong> app version is needed otherwise they all show up as “Unknown”</h2>
<p>This is important! You’ll need to add a patch for <em>every version</em> (other than the latest version, which you’ve already added) of your application that currently exists in your environment. In this case, I added patches for some of the older GlobalProtect versions:</p>

<p>If you <em>don’t</em> define patches for every version, they’ll show up as an “Unknown” version in the JAMF Patch Management report:
<img src="https://i.imgur.com/WcF7k6F.png" alt="Undefined patch versions" /></p>

<p>As you can see, that report isn’t very helpful; although I defined the latest version (5.2.6) the other versions floating around show up as “Unknown”. For this reason I strongly urge you to take the time and create a patch for each app version.</p>

<p>Anyways, at this point, you’re done! 🎉 You’ve successfully installed Kinobi, connected to JAMF, and created your first software title &amp; associcated patch(es).</p>

<h2 id="-using-kinobi-seems-like-a-lot-of-clicking-around-in-the-gui-isnt-there-a-way-to-to-automate-this">🤔 Using Kinobi seems like a lot of clicking around in the GUI. Isn’t there a way to to automate this?</h2>
<p>You’re right! It gets annoying especially if you have lots of apps. Luckily, Kinobi allows you to manually upload JSON definitions for patches.
<em>But how do you automatically generate those JSON files</em>? That’s where <a href="https://github.com/brysontyrrell/Patch-Starter-Script">Patch Starter Script</a> comes into play. It’s a Python script that will spit out a fully defined JSON file for you for any application. Then you can just upload that JSON into Kinobi. But that deserves its own blog post in the future! I’ll link to the new post once I’ve finished setting PSS up in my own environment and finish the write up.</p>]]></content><author><name>Sahib Bhai</name></author><summary type="html"><![CDATA[Context We use JAMF at our company but had a problem with keeping our line-of-business (LOB) applications updated. JAMF’s Patch Management catalog, while extensive, doesn’t include applications hiding behind a vendor’s paywall: VPN clients, NAC agents, AV/EDR agents, etc. In our case, one of those applications is Palo Alto GlobalProtect. While the application has its own auto-upgrade mechanism, I’m using it here as an example of what you can do with Kinobi Open-Source.]]></summary></entry></feed>