<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Timothy Kelvin</title>
    <description>The latest articles on DEV Community by Timothy Kelvin (@timmkal01).</description>
    <link>https://dev.to/timmkal01</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110897%2F4db131a3-cc29-429d-9ed4-47b61ce0761e.jpg</url>
      <title>DEV Community: Timothy Kelvin</title>
      <link>https://dev.to/timmkal01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/timmkal01"/>
    <language>en</language>
    <item>
      <title>I Built an Economic Calendar From Government Sources Instead of Scraping ForexFactory</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Fri, 25 Sep 2026 17:48:26 +0000</pubDate>
      <link>https://dev.to/timmkal01/i-built-an-economic-calendar-from-government-sources-instead-of-scraping-forexfactory-5ahk</link>
      <guid>https://dev.to/timmkal01/i-built-an-economic-calendar-from-government-sources-instead-of-scraping-forexfactory-5ahk</guid>
      <description>&lt;p&gt;The most popular economic calendar actors on the Apify Store scrape ForexFactory or Investing.com. Their own docs tell the story: the calendar page blocks datacenter IPs, so you need residential proxies "at any meaningful volume," and a browser for anything past the current week.&lt;/p&gt;

&lt;p&gt;But every number on those calendars comes from somewhere. The jobs report comes from the Bureau of Labor Statistics. GDP comes from the Bureau of Economic Analysis. The Fed publishes its own meeting dates. So I built &lt;a href="https://apify.com/m_ctim/us-economic-calendar" rel="noopener noreferrer"&gt;a calendar&lt;/a&gt; that reads the agencies directly: no proxies, no browser, and a run finishes in about six seconds.&lt;/p&gt;

&lt;p&gt;Getting there took more than five fetch calls. Here's what I ran into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five sources, four formats
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BLS&lt;/td&gt;
&lt;td&gt;Jobs report, CPI, PPI, JOLTS&lt;/td&gt;
&lt;td&gt;iCalendar feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BEA&lt;/td&gt;
&lt;td&gt;GDP, PCE inflation, trade&lt;/td&gt;
&lt;td&gt;iCalendar feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Census&lt;/td&gt;
&lt;td&gt;Retail sales, housing starts, durable goods&lt;/td&gt;
&lt;td&gt;HTML table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Federal Reserve&lt;/td&gt;
&lt;td&gt;FOMC decisions&lt;/td&gt;
&lt;td&gt;HTML page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Treasury&lt;/td&gt;
&lt;td&gt;Bill, note and bond auctions&lt;/td&gt;
&lt;td&gt;JSON API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two agencies publishing &lt;code&gt;.ics&lt;/code&gt; files was the nicest surprise. That's the format your phone's calendar app subscribes to, and it's meant to be read by software.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. BLS returns 403 unless you say who you are
&lt;/h2&gt;

&lt;p&gt;My first request to the BLS calendar came back &lt;code&gt;403 Forbidden&lt;/code&gt;. My instinct was that this was bot protection, and my rule for this portfolio is simple: if a site is actively blocking me, I don't try to get around it.&lt;/p&gt;

&lt;p&gt;So I tried the honest version first: a User-Agent that names the tool and links to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Agencies block anonymous clients; an honest, identifying User-Agent is what&lt;/span&gt;
&lt;span class="c1"&gt;// their access guidance asks for. This is identification, not disguise.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;USER_AGENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-economic-calendar/0.1 (+https://apify.com/m_ctim/us-economic-calendar)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200 OK&lt;/code&gt;. BLS doesn't block scripts, it blocks &lt;em&gt;anonymous&lt;/em&gt; scripts. There's a real difference between telling a server who you are and pretending to be Chrome, and it's worth trying the first before assuming you'd need the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. iCalendar lines fold
&lt;/h2&gt;

&lt;p&gt;A long &lt;code&gt;SUMMARY&lt;/code&gt; in an &lt;code&gt;.ics&lt;/code&gt; file gets split across lines, with the continuation starting with a space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUMMARY:Gross Domestic Product\, 3rd Quarter 2025 (Updated Estimate)\, GDP
  by Industry\, and Corporate Profits (Revised)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Split on newlines naively and you get half a title. The fix is one regex before splitting, plus unescaping the commas and semicolons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RFC 5545 line folding: a line starting with a space or tab continues the previous one.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\n[&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colon&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;n/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\([&lt;/span&gt;&lt;span class="sr"&gt;,;&lt;/span&gt;&lt;span class="se"&gt;\\])&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Two agencies, two ways of writing time
&lt;/h2&gt;

&lt;p&gt;BLS writes local Eastern time with a timezone label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DTSTART;TZID=US-Eastern:20260103T100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BEA writes UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DTSTART:20260122T133000Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traders want both Eastern (how releases are announced) and UTC (how their systems store time), so every row needs a correct conversion, and daylight saving makes that fiddly. 8:30 AM Eastern is 12:30 UTC in October and 13:30 UTC in November.&lt;/p&gt;

&lt;p&gt;Rather than pull in a date library, I used &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; to find the Eastern offset at a given instant. The one subtlety is that you don't know the offset until you know the instant, and you don't know the instant until you know the offset. Two passes settle it, including on the days the clocks change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;etToUtcIso&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dateStr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeStr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Two passes settle the offset correctly on DST transition days.&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;etOffsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;etOffsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test I trusted: the November 6 jobs report, after the clocks go back on November 1, comes out as &lt;code&gt;13:30:00Z&lt;/code&gt;. October releases come out as &lt;code&gt;12:30:00Z&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. BEA names GDP two different ways
&lt;/h2&gt;

&lt;p&gt;Each release gets an impact rating: high for the ones that move markets (jobs, CPI, GDP, the Fed), lower for regional and niche series. My rule for GDP was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="nx"&gt;Gross&lt;/span&gt; &lt;span class="nx"&gt;Domestic&lt;/span&gt; &lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first full-month test looked fine at a glance. Then I listed &lt;em&gt;every&lt;/em&gt; row, low impact included, and found Q3 GDP sitting there rated low. BEA's feed calls some releases "Gross Domestic Product, ..." and others "GDP (Advance Estimate), ...". Same release family, two spellings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BEA titles it both ways: "Gross Domestic Product, ..." and "GDP (Advance Estimate), ...".&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;Gross Domestic Product|GDP&lt;/span&gt;&lt;span class="se"&gt;)\b(?!&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;by &lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;State|County|Metro&lt;/span&gt;&lt;span class="se"&gt;))&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;growth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson I keep relearning: check the rows your filter &lt;em&gt;excluded&lt;/em&gt;, not just the ones it kept. The high-impact list looked plausible precisely because nothing in it was wrong. Something was just missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Some releases belong to two agencies
&lt;/h2&gt;

&lt;p&gt;The monthly trade report is a joint BEA and Census release, and both calendars list it. Without handling that, it shows up twice. Titles differ slightly ("U.S. International Trade in Goods and Services, August 2026" versus the same without the period), so the dedupe key uses the date, the time, and the title up to its first comma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeEt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;normTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row that survives records the other agency in an &lt;code&gt;alsoPublishedBy&lt;/code&gt; field, so nothing is silently dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The Fed's calendar has its own grammar
&lt;/h2&gt;

&lt;p&gt;The FOMC page lists each meeting as a month and a day range. Most are simple, like &lt;code&gt;March&lt;/code&gt; and &lt;code&gt;17-18*&lt;/code&gt;. Then there are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Meetings that span two months:&lt;/strong&gt; &lt;code&gt;Apr/May&lt;/code&gt; with &lt;code&gt;30-1&lt;/code&gt;. The decision comes on the last day, so the date is May 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The asterisk:&lt;/strong&gt; it marks meetings that come with the Summary of Economic Projections, the "dot plot." That goes in the row's title and notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notation votes:&lt;/strong&gt; entries like &lt;code&gt;22 (notation vote)&lt;/code&gt;. There's no 2:00 PM announcement to schedule around, so they're skipped.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dateText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;months&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;monthText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monthIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;months&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;months&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasProjections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dateText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Don't invent data the source doesn't have
&lt;/h2&gt;

&lt;p&gt;Treasury's Fiscal Data API lists announced auctions, with two quirks.&lt;/p&gt;

&lt;p&gt;First, it republishes every auction daily under a new &lt;code&gt;record_date&lt;/code&gt;, so the same auction appears several times. Keying on CUSIP plus auction date fixes that.&lt;/p&gt;

&lt;p&gt;Second, it has no auction time. By convention bills close at 11:30 AM Eastern and notes and bonds at 1:00 PM, and it would have been easy to fill that in. But a convention isn't a published time, and a calendar people schedule trades around shouldn't guess. Auction rows return &lt;code&gt;timeEt: null&lt;/code&gt;, and the README says why.&lt;/p&gt;

&lt;p&gt;The same rule applies to the biggest thing this calendar lacks: consensus forecasts. Those come from private data vendors, not the agencies. The ForexFactory scrapers have them because ForexFactory compiles them. An official-source calendar can't, so this one doesn't pretend to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it returns
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-29"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeEt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"08:30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestampUtc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-29T12:30:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BEA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GDP (Advance Estimate), 3rd Quarter 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3rd Quarter 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"impact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"growth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.bea.gov/news/schedule"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter by date range, agency, impact or keyword. It charges once per run, so a month of releases costs the same as a day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Try it: &lt;a href="https://apify.com/m_ctim/us-economic-calendar" rel="noopener noreferrer"&gt;US Economic Calendar on Apify&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/timmKal01/us-economic-calendar" rel="noopener noreferrer"&gt;github.com/timmKal01/us-economic-calendar&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you work with macro data, I'd like to know what you'd add. ECB and Bank of England calendars are the obvious next step.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>opendata</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What crt.sh's Error Pages Taught Me About Retry Logic</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:03:00 +0000</pubDate>
      <link>https://dev.to/timmkal01/what-crtshs-error-pages-taught-me-about-retry-logic-15dj</link>
      <guid>https://dev.to/timmkal01/what-crtshs-error-pages-taught-me-about-retry-logic-15dj</guid>
      <description>&lt;p&gt;I built a small Apify actor that watches &lt;a href="https://certificate.transparency.dev/" rel="noopener noreferrer"&gt;Certificate Transparency logs&lt;/a&gt; for a domain, basically "tell me every SSL cert that's been issued for this domain or its subdomains recently." Useful for catching phishing look-alikes and shadow IT before a customer complaint does. The data source is &lt;a href="https://crt.sh/" rel="noopener noreferrer"&gt;crt.sh&lt;/a&gt;, a free community-run search service over CT log data. Getting the actual query working took ten minutes. Making it reliable took a lot longer, and taught me more about "empty result" ambiguity than I expected from what's basically a single GET request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First problem: 404 doesn't mean "no results."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;crt.sh returns bare HTML error pages, not JSON, when it's under load. I first treated a 404 as "no certificates found," which is a completely reasonable read of a 404. Except I'd query the same domain twice in a row and get a 404 once and a real result the next time. Same query, same data, different outcome, because the 404 wasn't about the data at all, it was crt.sh being overloaded. Same story with 502/503/504. None of these are trustworthy "zero results" signals for this service, so they all need to be retried, not accepted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second problem: my retry budget wasn't big enough for a real outage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started with 4 attempts and exponential backoff, which felt generous. Then during a bad patch I watched it eat 6 consecutive 502s before a success came through, and the actor's rolling 30-day failure rate had climbed to something like 46%. Four attempts just wasn't enough runway to ride out a real bad stretch on a free community service. Bumped it to 8, and capped the backoff instead of letting it grow unbounded, since a successful response can itself take 10-20 seconds once crt.sh is under load, so the retry loop needs real time budget, not just more attempts fired quickly at each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third problem: a hung connection defeats retries entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Plain &lt;code&gt;fetch()&lt;/code&gt; has no timeout. One test run just hung, no 502, no slow success, nothing, indefinitely. If a request never resolves, it never reaches the point where my retry logic would even kick in. Added an &lt;code&gt;AbortController&lt;/code&gt; with a per-attempt timeout so a dead connection gets treated as a failure and retried like any other, instead of silently stalling the whole run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fourth problem, and the sneaky one: a field just disappeared from the response shape.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The actor filters results by date and sorts newest-first, originally using crt.sh's &lt;code&gt;entry_timestamp&lt;/code&gt; field. At some point crt.sh's JSON output for this particular query stopped including that field. No error, no warning, just &lt;code&gt;undefined&lt;/code&gt;. Which meant &lt;code&gt;new Date(undefined) &amp;gt;= startDate&lt;/code&gt; silently evaluated false for every single entry, so the actor returned zero results, every time, regardless of what data actually existed. The scariest kind of bug for a monitoring tool: it fails by going quiet, not by throwing. Switched to &lt;code&gt;not_before&lt;/code&gt; (when the cert becomes valid, which is essentially CT-log time anyway) as the log-time proxy instead, since that field is always present.&lt;/p&gt;

&lt;p&gt;None of these were exotic failures. Bare error pages under load, a budget that was too small, a fetch with no timeout, a field that quietly vanished. But stacked together they're the difference between "works when I test it" and "actually trustworthy as a monitoring tool," which matters a lot more once the whole point of the thing is telling you about certificates you didn't expect.&lt;/p&gt;

&lt;p&gt;If you want to poke at the actual retry code: &lt;a href="https://github.com/timmKal01/certificate-transparency-monitor" rel="noopener noreferrer"&gt;github.com/timmKal01/certificate-transparency-monitor&lt;/a&gt;. The actor itself is live on &lt;a href="https://apify.com/m_ctim/certificate-transparency-monitor" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; if you want to point it at a domain.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>"Pull a company's open roles straight from their own job board API"</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Fri, 18 Sep 2026 10:05:15 +0000</pubDate>
      <link>https://dev.to/timmkal01/pull-a-companys-open-roles-straight-from-their-own-job-board-api-3c7a</link>
      <guid>https://dev.to/timmkal01/pull-a-companys-open-roles-straight-from-their-own-job-board-api-3c7a</guid>
      <description>&lt;p&gt;Most "company hiring signal" tools scrape LinkedIn. company-hiring-tracker does something simpler: most companies that use Greenhouse or Lever for hiring expose their open roles through a public, unauthenticated API on their own job board. No login, no proxy, no headless browser, just a fetch.&lt;/p&gt;

&lt;p&gt;Give it a company's ATS (Greenhouse or Lever) and board slug, and it returns each open role: title, location, department, and posting date, as clean JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Senior Backend Engineer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"department"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"postedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-10"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priced per board queried, not per job returned, so checking a company with 40 open roles costs the same as checking one with 2.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/m_ctim/company-hiring-tracker" rel="noopener noreferrer"&gt;https://apify.com/m_ctim/company-hiring-tracker&lt;/a&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>api</category>
      <category>javascript</category>
    </item>
    <item>
      <title>"A Raw JSON Textarea Was Quietly Costing Me Users"</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Fri, 18 Sep 2026 09:57:34 +0000</pubDate>
      <link>https://dev.to/timmkal01/a-raw-json-textarea-was-quietly-costing-me-users-4nek</link>
      <guid>https://dev.to/timmkal01/a-raw-json-textarea-was-quietly-costing-me-users-4nek</guid>
      <description>&lt;p&gt;I almost missed this one because the actor itself worked fine. The bug wasn't in the code, it was in the input form.&lt;/p&gt;

&lt;p&gt;Looking at usage funnels across a portfolio of small data-fetching actors (Apify calls them Actors — serverless scrapers/API tools), one number stood out: for company-buying-signal-report, 5 people opened the input page, and only 1 actually ran it. Everywhere else in the funnel, the drop-off was gradual. Here it was a cliff.&lt;/p&gt;

&lt;p&gt;The only clue was the input schema itself. The actor's one real input field was &lt;code&gt;companies&lt;/code&gt;, an array of objects (&lt;code&gt;{ domain, greenhouseSlug, leverSlug }&lt;/code&gt;), rendered by Apify's console as a raw JSON editor. Technically correct, since most people running this actor only care about one company at a time, and a blank JSON textarea is not exactly a welcoming way to ask for that.&lt;/p&gt;

&lt;p&gt;The fix was small: add flat, single-item fields (&lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;greenhouseSlug&lt;/code&gt;, &lt;code&gt;leverSlug&lt;/code&gt;) alongside the existing array field. In the actor's code, the single fields take priority when filled in, and fall back to the array for anyone who does want to check multiple companies in one run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;companies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;singleDomain&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;singleDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;greenhouseSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leverSlug&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;companiesInput&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;companiesInput&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultCompanies&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once I saw it in one actor, I grepped the rest of the portfolio for the same pattern (&lt;code&gt;"editor": "json"&lt;/code&gt; on an array-of-objects field with no matching single-item shortcut) and found four more actors with the exact same friction: a hiring tracker, a weather tracker, a risk-briefing tool, and a city council monitor. Same fix, same result, each verified live afterward.&lt;/p&gt;

&lt;p&gt;None of this shows up in a code review. It only shows up when you look at where real people stop, not just whether the backend logic is correct.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>webscraping</category>
      <category>api</category>
      <category>ux</category>
    </item>
    <item>
      <title>There are three different things people mean by "check my SSL cert"</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:18:35 +0000</pubDate>
      <link>https://dev.to/timmkal01/there-are-three-different-things-people-mean-by-check-my-ssl-cert-4foo</link>
      <guid>https://dev.to/timmkal01/there-are-three-different-things-people-mean-by-check-my-ssl-cert-4foo</guid>
      <description>&lt;p&gt;A cert expiring in production is one of those outages that feels avoidable in hindsight. Nobody meant to skip it. It just wasn't anyone's job to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confusing part
&lt;/h2&gt;

&lt;p&gt;"Check my SSL cert" can mean three different things, and they check three different systems.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the domain registration itself about to expire? That's a WHOIS/RDAP question, nothing to do with TLS.&lt;/li&gt;
&lt;li&gt;Has a new certificate been issued for this domain recently, maybe one you didn't request? That's a certificate transparency log question, a public append-only record of every cert any browser-trusted CA has ever issued.&lt;/li&gt;
&lt;li&gt;What certificate is the server actually presenting right now, and how many days until it expires? That's a live TLS handshake question, and it's the one that actually causes an outage when the answer is "zero."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd already built tools for the first two. This one covers the third, and it's the one people usually mean when a pager goes off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Opens a direct TLS connection to each host, the same handshake a browser performs, and reads the certificate the server presents. Node's built-in &lt;code&gt;tls&lt;/code&gt; module does the heavy lifting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;servername&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rejectUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPeerCertificate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authorized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorized&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rejectUnauthorized: false&lt;/code&gt; looks backwards at first. The point isn't to skip trust checking, it's to keep the connection open long enough to actually read the certificate even when it's self-signed or the chain is broken. &lt;code&gt;socket.authorized&lt;/code&gt; and &lt;code&gt;socket.authorizationError&lt;/code&gt; still report the trust result separately, so a broken cert shows up as data ("isSelfSigned: true", a trust error message) instead of a connection failure with no detail. Silently failing to connect isn't the same as telling someone their cert is bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it returns
&lt;/h2&gt;

&lt;p&gt;One record per host: issuer, subject, valid dates, days until expiry, expired/self-signed flags, and the trust result. A host that can't be reached at all still returns a record, just with &lt;code&gt;reachable: false&lt;/code&gt; and an error message, since "we tried to check and something's wrong" is itself a useful result for a host that should have been reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing shape
&lt;/h2&gt;

&lt;p&gt;Billed per host checked, not per field returned. One charge whether the host is fine, expired, or completely unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/timmKal01/ssl-certificate-expiry-checker" rel="noopener noreferrer"&gt;github.com/timmKal01/ssl-certificate-expiry-checker&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hosted version (Apify actor, no setup): &lt;a href="https://apify.com/m_ctim/ssl-certificate-expiry-checker" rel="noopener noreferrer"&gt;apify.com/m_ctim/ssl-certificate-expiry-checker&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're already checking domain expiry or watching CT logs, this is the third leg of that stool, the one that actually matches what's live on the wire right now.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>Three small API-backed actors I've shipped recently (Docker Hub, lead extraction, SEC Form 4)</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Fri, 11 Sep 2026 19:24:35 +0000</pubDate>
      <link>https://dev.to/timmkal01/three-small-api-backed-actors-ive-shipped-recently-docker-hub-lead-extraction-sec-form-4-2p6</link>
      <guid>https://dev.to/timmkal01/three-small-api-backed-actors-ive-shipped-recently-docker-hub-lead-extraction-sec-form-4-2p6</guid>
      <description>&lt;p&gt;I build small, narrow Apify actors — each one wraps a single official API or data source and does one job well, rather than trying to be a general-purpose scraper. Here are three recent ones, each solving a different, specific problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Hub Image Tracker
&lt;/h2&gt;

&lt;p&gt;Docker Hub will repush a tag with a new digest, new architectures, or a different underlying image, and the version string stays the same. This actor hits &lt;code&gt;hub.docker.com/v2/repositories&lt;/code&gt; directly and tells you what actually changed for a given repository within a time window — digest, size, supported architectures, last-pushed/last-pulled timestamps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repository"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"daysBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real gotcha from building it: the API's &lt;code&gt;ordering&lt;/code&gt; param is backwards from the usual REST convention — &lt;code&gt;ordering=last_updated&lt;/code&gt; sorts newest-first, and you need a &lt;code&gt;-&lt;/code&gt; prefix to get oldest-first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/m_ctim/docker-hub-image-tracker" rel="noopener noreferrer"&gt;apify.com/m_ctim/docker-hub-image-tracker&lt;/a&gt; · &lt;a href="https://github.com/timmKal01/docker-hub-image-tracker" rel="noopener noreferrer"&gt;source&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Website Lead Extractor
&lt;/h2&gt;

&lt;p&gt;Built on Crawlee's plain &lt;code&gt;CheerioCrawler&lt;/code&gt; instead of a headless browser. Point it at a domain, it crawls within that domain up to a set depth, and pulls every email, phone number, and social profile link (LinkedIn, X, Facebook, Instagram, GitHub) it finds in the raw HTML — no Chromium, no render wait.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"startUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxDepth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxPagesPerDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tradeoff worth stating plainly: it only sees what's in the initial HTML response, so a client-side-only React SPA with no SSR needs a browser-based crawler instead. For the common case of a static or server-rendered marketing site, it's faster and has nothing to configure around headless-browser flakiness.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/m_ctim/website-lead-extractor" rel="noopener noreferrer"&gt;apify.com/m_ctim/website-lead-extractor&lt;/a&gt; · &lt;a href="https://github.com/timmKal01/website-lead-extractor" rel="noopener noreferrer"&gt;source&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Insider Trading Alert
&lt;/h2&gt;

&lt;p&gt;Every SEC Form 4 filing (executive/director/10%-owner stock buys and sells) is public, structured XML, filed within a couple of business days of the transaction. This actor reads EDGAR's filing feed and each filing's own XML directly — search one ticker or scan the market-wide feed of the newest filings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ticker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AAPL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"transactionType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disposed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minTransactionValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real bug from building it: the boolean "is this person an officer/director" flags get encoded as &lt;code&gt;1&lt;/code&gt;/&lt;code&gt;0&lt;/code&gt; by some filers' software and &lt;code&gt;true&lt;/code&gt;/absent by others, on the same form type. A naive &lt;code&gt;=== '1'&lt;/code&gt; check silently returned false for real officers depending on which software generated the filing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/m_ctim/insider-trading-alert" rel="noopener noreferrer"&gt;apify.com/m_ctim/insider-trading-alert&lt;/a&gt; · &lt;a href="https://github.com/timmKal01/insider-trading-alert" rel="noopener noreferrer"&gt;source&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;All three are pay-per-event on Apify (no subscription), no proxy, and read directly from each source's own official API rather than scraping rendered pages.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>api</category>
      <category>webscraping</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Turning a list of company websites into a contact list with a plain HTTP crawl</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:20:16 +0000</pubDate>
      <link>https://dev.to/timmkal01/turning-a-list-of-company-websites-into-a-contact-list-with-a-plain-http-crawl-42pf</link>
      <guid>https://dev.to/timmkal01/turning-a-list-of-company-websites-into-a-contact-list-with-a-plain-http-crawl-42pf</guid>
      <description>&lt;p&gt;Anyone who has done outbound sales or recruiting knows this task. You have a list of company websites and you need the contact info off each one. Open the Contact page, copy the email, check LinkedIn, move to the next site. A 50 domain list eats most of an afternoon done by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;Give it a start URL and it crawls within that same domain, up to a depth you choose, using Crawlee's &lt;code&gt;CheerioCrawler&lt;/code&gt;. On each page it checks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email addresses in the raw HTML&lt;/li&gt;
&lt;li&gt;Phone numbers&lt;/li&gt;
&lt;li&gt;Social profile links (LinkedIn, X, Facebook, Instagram, GitHub)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything found gets returned as one record per page, with the URL, domain, and whatever contact info was on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/contact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello@example.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"+1 415-555-0132"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"socialProfiles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"linkedin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://linkedin.com/company/example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"twitter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://x.com/example"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;p&gt;No headless browser. It only reads what the site already renders in the raw HTML response to a plain request, the same thing any visitor's browser would see before JavaScript runs. That keeps it fast and cheap to run, and it means it only extracts information the site is already publishing publicly. Nothing behind a login, nothing a normal visitor couldn't already find on a Contact or About page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls short, on purpose
&lt;/h2&gt;

&lt;p&gt;Sites that render their content client side with JavaScript are a real weak spot here. A plain HTTP crawl never sees content that only shows up after JS runs, so a heavily client rendered site might come back with far less than it actually has. That's the honest tradeoff for staying fast, cheap, and proxy free. A browser based crawler would handle those sites but costs more to run and adds real complexity. For static or server rendered company sites, which is most small and mid size business sites, the plain HTTP approach works well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing shape
&lt;/h2&gt;

&lt;p&gt;Billed per page where contact info was actually found. A domain with nothing public anywhere costs nothing to check, and a domain where three pages all list contact info costs about the same as pulling it from all three yourself, just a lot faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/timmKal01/website-lead-extractor" rel="noopener noreferrer"&gt;github.com/timmKal01/website-lead-extractor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hosted version (Apify actor, no setup): &lt;a href="https://apify.com/m_ctim/website-lead-extractor" rel="noopener noreferrer"&gt;apify.com/m_ctim/website-lead-extractor&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run it against a list of your own domains, curious to hear how the hit rate looks on real sites.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>scraping</category>
      <category>node</category>
    </item>
    <item>
      <title>I built an Apify Actor that turns a list of company domains into a ranked "who to call first" list</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Mon, 07 Sep 2026 09:14:06 +0000</pubDate>
      <link>https://dev.to/timmkal01/i-built-an-apify-actor-that-turns-a-list-of-company-domains-into-a-ranked-who-to-call-first-list-45gn</link>
      <guid>https://dev.to/timmkal01/i-built-an-apify-actor-that-turns-a-list-of-company-domains-into-a-ranked-who-to-call-first-list-45gn</guid>
      <description>&lt;p&gt;If you've ever done account-based prospecting, you know the drill: pull open&lt;br&gt;
roles from a company's careers page, eyeball their homepage for what tools&lt;br&gt;
they're running, dig up a contact email, then repeat that for every account&lt;br&gt;
on the list — and manually decide who actually looks worth calling first.&lt;/p&gt;

&lt;p&gt;I build small data-focused Apify Actors as a side project, and this one&lt;br&gt;
(&lt;strong&gt;Company Buying Signal Report&lt;/strong&gt;) started from wanting to stop doing that&lt;br&gt;
join by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Give it a list of company domains. For each one it pulls three signals in&lt;br&gt;
parallel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hiring signal&lt;/strong&gt; — open roles straight from the company's own Greenhouse
or Lever job board API (the same public JSON endpoints that power their
careers page), matched against a list of role keywords you care about
(&lt;code&gt;"account executive"&lt;/code&gt;, &lt;code&gt;"growth"&lt;/code&gt;, whatever signals urgency for your
product).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech stack fingerprint&lt;/strong&gt; — CMS, ecommerce platform, analytics, payment,
and live-chat tools detected from response headers, meta tags, and script
sources on the homepage — the same signature-matching approach tools like
Wappalyzer use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public contact info&lt;/strong&gt; — email, phone, and social links pulled from the
homepage and one linked contact/about page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it computes a &lt;code&gt;low&lt;/code&gt;/&lt;code&gt;medium&lt;/code&gt;/&lt;code&gt;high&lt;/code&gt; buying-signal score with the actual&lt;br&gt;
reasons behind it (&lt;code&gt;"142 open roles (actively scaling)"&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;"uses paid ecommerce/payment/chat/analytics tooling"&lt;/code&gt;), so the output is a&lt;br&gt;
ranked call list, not three raw feeds you still have to reconcile yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I actually had to think about: slugs
&lt;/h2&gt;

&lt;p&gt;Greenhouse and Lever both key their public job-board API by a "slug" —&lt;br&gt;
&lt;code&gt;boards-api.greenhouse.io/v1/boards/{slug}/jobs&lt;/code&gt; — and that slug usually,&lt;br&gt;
but not always, matches the company's domain name. &lt;code&gt;stripe.com&lt;/code&gt; → &lt;code&gt;stripe&lt;/code&gt;&lt;br&gt;
works. Plenty of companies don't follow that pattern.&lt;/p&gt;

&lt;p&gt;The actor guesses the slug from the domain by default (fast, works most of&lt;br&gt;
the time), but lets you pass &lt;code&gt;greenhouseSlug&lt;/code&gt;/&lt;code&gt;leverSlug&lt;/code&gt; explicitly per&lt;br&gt;
company when you already know it (it's sitting right there in the careers&lt;br&gt;
page URL). Rather than silently returning nothing for a guess that misses,&lt;br&gt;
it's an explicit opt-in override — a small design choice, but the kind of&lt;br&gt;
thing that's easy to get wrong in either direction (over-trusting a guess,&lt;br&gt;
or forcing every user to look up slugs manually even when the guess would've&lt;br&gt;
worked fine).&lt;/p&gt;

&lt;h2&gt;
  
  
  No proxy, no login, no headless browser
&lt;/h2&gt;

&lt;p&gt;All three signals come from data companies already publish for anyone to&lt;br&gt;
see — their own careers-page API, their own homepage HTML, their own contact&lt;br&gt;
page. No scraping behind auth, no browser automation, no residential proxy&lt;br&gt;
bill. It's just three passive HTTP lookups run per company and joined into&lt;br&gt;
one row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why billed per company, not per signal
&lt;/h2&gt;

&lt;p&gt;It's priced as one charge per company report, regardless of whether that&lt;br&gt;
company has 3 open roles or 300, or how many tech-stack signatures matched.&lt;br&gt;
The point of combining the three lookups was to replace three separate tool&lt;br&gt;
runs (and the spreadsheet join after) with one — pricing it per sub-signal&lt;br&gt;
would've defeated that.&lt;/p&gt;




&lt;p&gt;If you're doing target-account prioritization and this saves you a step:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://apify.com/m_ctim/company-buying-signal-report" rel="noopener noreferrer"&gt;try it on Apify&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
· &lt;strong&gt;&lt;a href="https://github.com/timmKal01/company-buying-signal-report" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the Greenhouse/Lever API shapes or the&lt;br&gt;
signature-matching approach if anyone's building something similar.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>api</category>
      <category>node</category>
      <category>sales</category>
    </item>
    <item>
      <title>Fingerprinting a website's tech stack from a single HTTP request (no headless browser)</title>
      <dc:creator>Timothy Kelvin</dc:creator>
      <pubDate>Sat, 05 Sep 2026 08:47:43 +0000</pubDate>
      <link>https://dev.to/timmkal01/fingerprinting-a-websites-tech-stack-from-a-single-http-request-no-headless-browser-18l7</link>
      <guid>https://dev.to/timmkal01/fingerprinting-a-websites-tech-stack-from-a-single-http-request-no-headless-browser-18l7</guid>
      <description>&lt;p&gt;I wanted a fast way to answer "what's this site built with?" — CMS, ecommerce platform, JS framework, analytics tags, CDN, payment/chat widgets — without spinning up a full headless browser per URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;One plain HTTP fetch of the homepage via &lt;a href="https://crawlee.dev" rel="noopener noreferrer"&gt;Crawlee&lt;/a&gt;'s &lt;code&gt;CheerioCrawler&lt;/code&gt;, then check three things against a signature table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Response headers (&lt;code&gt;Server&lt;/code&gt;, &lt;code&gt;X-Powered-By&lt;/code&gt;, &lt;code&gt;CF-Ray&lt;/code&gt;, &lt;code&gt;X-Vercel-Id&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;&amp;lt;meta name="generator"&amp;gt;&lt;/code&gt; tag&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; domains on the page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same general idea as Wappalyzer, just a much smaller, hand-written signature set — about 30 technologies across 7 categories (CMS, ecommerce, JS frameworks, analytics, CDN/hosting, payment, live chat). Each signature is a &lt;code&gt;test(context)&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WordPress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;generator&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wordpress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;context&lt;/code&gt; gives you lowercased &lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;html&lt;/code&gt;, &lt;code&gt;scriptSrcs&lt;/code&gt;, and &lt;code&gt;generator&lt;/code&gt; — cheap to check, no DOM traversal needed for most signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No headless browser. Everything it reads is already served to any visitor's plain HTTP request — no JS execution, no login, no bypassing any protection.&lt;/li&gt;
&lt;li&gt;No guessing. If nothing in the signature table matches, it returns an empty result for that category rather than a fuzzy best-guess. A smaller-but-honest signature set beats a big one that's confidently wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it fell short (and stayed that way, on purpose)
&lt;/h2&gt;

&lt;p&gt;Detection accuracy is bounded by however many signatures I've hand-written — it's ~30 technologies, not the hundreds Wappalyzer tracks. Sites using something niche just come back with an empty category instead of a wrong guess. That's a real limitation, not a bug, and the tradeoff I'd make again: adding a signature is one function, so it grows exactly as fast as real usage demands it, not ahead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/timmKal01/website-tech-stack-detector" rel="noopener noreferrer"&gt;github.com/timmKal01/website-tech-stack-detector&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hosted version (Apify actor, no setup): &lt;a href="https://apify.com/m_ctim/website-tech-stack-detector" rel="noopener noreferrer"&gt;apify.com/m_ctim/website-tech-stack-detector&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got a signature I'm missing, &lt;code&gt;signatures.js&lt;/code&gt; is a small file — happy to take PRs or just hear about the gap.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>scraping</category>
      <category>node</category>
    </item>
  </channel>
</rss>
