<?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: Delta Tools</title>
    <description>The latest articles on DEV Community by Delta Tools (@deltatools).</description>
    <link>https://dev.to/deltatools</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%2F4157800%2Fce41505c-e4f3-4db7-b7c9-195937552227.png</url>
      <title>DEV Community: Delta Tools</title>
      <link>https://dev.to/deltatools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deltatools"/>
    <language>en</language>
    <item>
      <title>How to Check a Business Name for Trademark Problems Before You Build the Brand</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Fri, 23 Oct 2026 16:36:26 +0000</pubDate>
      <link>https://dev.to/deltatools/how-to-check-a-business-name-for-trademark-problems-before-you-build-the-brand-4fbf</link>
      <guid>https://dev.to/deltatools/how-to-check-a-business-name-for-trademark-problems-before-you-build-the-brand-4fbf</guid>
      <description>&lt;h1&gt;
  
  
  How to Check a Business Name for Trademark Problems Before You Build the Brand
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Check a business name against live USPTO trademark records before you pay for a logo, a domain, packaging, or launch copy. Start with the exact name. Then search the distinctive word on its own, the distinctive word with the product word, and spelling or sound variants a customer might use. Read live records first, and judge them by class together with related goods and services. An exact search can return nothing while a close live mark is sitting in the same trade space. A screening pass helps because it scores near matches, groups them by class, names the owner and status, and gives a plain reason for each match. Treat the results as research. A legal clearance decision needs more than a screening pass.&lt;/p&gt;

&lt;p&gt;You have the name on a sticky note. It is on your phone. It is in the group chat. Someone has already said it out loud in the voice you would use in an ad, and everyone nodded like the hard part was over. The domain looks available. The social handle looks available. A designer can start Monday. The label printer can start as soon as the logo lands. This is the exact moment to slow down for one unglamorous job: check whether the name is already doing work for someone else in the trademark register.&lt;/p&gt;

&lt;p&gt;I have watched this scene from the wrong side too many times. A founder falls in love with the sound of a name before asking whether the name can safely carry money. The love is understandable. Naming is the first piece of the business that feels real. It is also the cheapest piece to change before the brand is printed, posted, packaged, and promised to customers.&lt;/p&gt;

&lt;p&gt;Think of a business name check as a practical pause. You are looking for live trademark records that are identical or close enough to raise a real question, especially when the goods or services are the same or related. A field full of similar live marks can mean confusion risk, weak distinctiveness, or both. A quiet field proves little on its own. It means the federal register did not show you an obvious problem in that pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer before you spend money
&lt;/h2&gt;

&lt;p&gt;Run four searches for every serious finalist. Search the exact name. Search the word that carries the brand. Search that word with the product or service word. Search the versions people might type after hearing the name once. For each live record that looks close, write down the mark, the owner, the status, the Nice class, the goods or services, and the reason it caught your eye.&lt;/p&gt;

&lt;p&gt;Then make a calmer decision. If the closest live marks share your distinctive word and sell the same or related things, pause and get advice before you build. If the close marks are in unrelated fields, the name may still be usable, but you now know why you think so. If the search shows only descriptive overlap, note that too. If the field looks crowded in several directions, ask whether the name deserves the fight or is simply the first name the team learned to love. Keep the notes either way, and rename while renaming is still an afternoon. The lasting value sits in the reasons you gather before emotion spends the budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why available everywhere else can still mislead you
&lt;/h2&gt;

&lt;p&gt;A domain registrar answers a narrow question: is this exact web address unclaimed right now. A social platform answers a similar narrow question about a handle. Neither one searches trademark rights. A name can be free as a domain and still be taken as a mark for the thing you plan to sell. These systems do not check with each other.&lt;/p&gt;

&lt;p&gt;The trademark question is about source confusion in the market. If customers see two similar names on the same or related goods or services, would they wonder whether the products come from the same company or from connected companies. That is why a name check cannot stop at identical text. Spelling can change while the sound stays close. A descriptive word can be swapped while the distinctive word remains the part customers remember.&lt;/p&gt;

&lt;p&gt;Class matters because trademark records are organized by the goods and services claimed. The Nice class is a sorting system for those claims. It helps you find the neighborhood where a mark lives. It does not build a wall around that neighborhood. Coffee sold as a product and coffee served in a café sit in different classes, yet a customer can meet both in the same morning from brands that feel connected. Related goods and services are the risk because customers do not experience your legal categories. They experience shelves, menus, search results, apps, and recommendations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual USPTO check
&lt;/h2&gt;

&lt;p&gt;The manual method works. It takes patience, and it teaches you the shape of the register before any tool scores anything for you. Use the USPTO Trademark Search system and work through the same steps for each finalist. You will find it at tmsearch.uspto.gov, and the basic search by mark text is the right place to begin. A search you cannot reconstruct later is only a mood.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Write the name the way customers will see it and say it. Put the exact spacing and capitalization aside for a moment, because customers will not preserve your styling when they search, speak, or recommend you. Say the name out loud. If it sounds like another word when spoken quickly, write that version down too.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Split the name into the part that carries memory and the part that describes the offer. In Nimbus Coffee Roasters, Nimbus is the part a customer is most likely to carry away. Coffee and Roasters describe the business. Descriptive words still matter, especially in crowded categories, but the memory word usually decides whether two names feel close.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search the exact name first. Look for live records before dead ones. For each serious record, note the mark text, owner, status, Nice class, and the goods or services language. A live exact match in your field deserves your full attention, because the search just did its job early.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search the memory word on its own. A founder searching only the full name may miss a shorter mark that contains the important word and covers the same customers. The shorter mark can be the one people remember, cite, and confuse with yours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search the memory word with the product or service word. Try the pairing customers would use if they described your business in plain language. For a coffee name, that means the brand word with coffee, café, roaster, or the service you actually plan to offer. Build your own final list from the words your customers actually use.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search variants that come from ears and keyboards. Change a letter. Drop a vowel. Swap the order of words. Write the plural and the singular. Write the version a person might type after hearing the name in a noisy room. Trademark risk often lives in the gap between what looks different on a screen and what sounds close in real life.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compare by class and by relatedness. Put the same class records at the top of your notes. Then look hard at related classes before you relax. Ask where the customer meets the goods or services and whether one brand could plausibly extend into the other space. A café service and packaged coffee are not the same claim, but they are close enough in a customer mind to deserve respect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Record the reason for every match you keep. A useful note says more than the name of the mark. It says whether the match is exact, whether it shares the memory word, whether it sounds close, whether the goods are related, and whether the record is live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Repeat the same pass for the final spelling you would actually file or print. Founders often check a working name and then adopt a cousin of it at the last minute. That cousin needs its own search. The register does not care that the change felt small in a design review.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This method is honest work, and for one name it may be enough to reveal an obvious problem. Its weakness appears when the shortlist grows and the dangerous match is not exact. You can spend a long afternoon opening records and copying fields, and still miss the mark that matters because the record is shorter, spelled a little differently, or built from the same memorable word.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to judge what the manual search finds
&lt;/h2&gt;

&lt;p&gt;Live records come first. A dead record can explain history or show that a field has been busy, but a live record is the one that can stand in your path now. Within live records, start with marks that share the distinctive part of your name. Many coffee brands can use the word coffee. Far fewer should use the same unusual brand word for the same kind of offer.&lt;/p&gt;

&lt;p&gt;Next, read the goods and services as a customer would. Strip the record down to what is sold, where it is sold, and who buys it. If those answers sit close to yours, treat the match as serious even when the classes differ. If the goods are truly unrelated, write that down as the reason the match worries you less. Do not let a class number do the thinking for you.&lt;/p&gt;

&lt;p&gt;Then look at the whole field. A crowd of live marks built on the same word can be a different kind of warning. It may mean the word is already doing source work for several owners, which can make your name harder to protect and easier to confuse. It may also mean the word is common in the trade. Either way, a crowded result should slow the celebration.&lt;/p&gt;

&lt;p&gt;Finally, remember what this search cannot see. The register is federal and public, so local use, state registers, and later filings sit outside it. A careful manual pass lowers surprise. It does not erase it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a screening pass adds
&lt;/h2&gt;

&lt;p&gt;Instead of relying only on the exact text you typed, a screening pass compares a candidate name with live USPTO records and scores similarity. The comparison can reward exact text, shared distinctive words, one name containing the other, small spelling changes, and words that sound alike. It can also give common descriptive words less weight than rare words that customers actually remember.&lt;/p&gt;

&lt;p&gt;A good screening result puts the closest live matches at the top, groups them by Nice class, and attaches the owner, the status, the similarity score, and the reason each match counted. You still have to read and judge. The difference is that your judgment starts from a ranked field instead of a scattered pile of records.&lt;/p&gt;

&lt;p&gt;Delta Tools built the Trademark Clearance Checker for this step. It screens a name against live USPTO records and returns one summary per name with exact and close matches, classes with live matches, a signal level, and the strongest matches with plain reasons. You can run it here: &lt;a href="https://apify.com/delta_tools/trademark-clearance-checker?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=trademark-name-check-before-brand"&gt;Trademark Clearance Checker&lt;/a&gt;. The manual method above is the skill. The checker is the faster pass when a shortlist needs the same treatment applied evenly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example with Nimbus Coffee Roasters
&lt;/h2&gt;

&lt;p&gt;Nimbus Coffee Roasters is a useful example because an exact search gives the kind of answer that can make a founder relax too early. In the screening run used for this article, the name returned a MODERATE signal. It had 0 live exact matches. It had 60 live near matches at similarity of 0.55 or higher, plus 56 dead records. Live matches appeared in Nice classes 016, 021, 025, 030, 032, 035, 040, and 043.&lt;/p&gt;

&lt;p&gt;Zero exact matches sounds clean until you read the top of the near match list. The strongest live match was NIMBUS COFFEE in class 043, owned by Nimbus Coffee LLC, with a similarity score of 0.863. The reasons matter as much as the number. The candidate contains the full text of that mark, shares NIMBUS and COFFEE, and sounds alike. Class 043 is the café service neighborhood, which is close to the heart of a coffee brand even when the candidate also plans to sell beans.&lt;/p&gt;

&lt;p&gt;The next match makes the class point even clearer. COFFEE ROASTERS appeared as a live mark in class 030 with a similarity score of 0.86 and owner Coffee Roasters, Inc. Another live match, NIRVANA COFFEE ROASTERS in class 030, scored 0.783. Some of the 60 near matches will be noise once a human reads them. That is normal. A screening list is not a verdict list. It is a reading list, ordered so the records most likely to matter get read first.&lt;/p&gt;

&lt;p&gt;MODERATE is the right emotional register for this result. MODERATE settles nothing by itself. It says the field is crowded enough that a founder should pause before ordering packaging. The exact search alone could have produced a calm little celebration. The screening pass shows why that celebration would have been premature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What screening a shortlist costs
&lt;/h2&gt;

&lt;p&gt;A run starts at 0.001 USD. Each completed screening costs 0.01 USD. A screening that fails does not add the per screening charge for that name.&lt;/p&gt;

&lt;p&gt;If ten names complete in one run, the math is 0.001 USD for the run start plus ten times 0.01 USD, which comes to 0.101 USD in event charges. If five finalists complete in one run, the math is 0.001 USD plus 0.05 USD, which comes to 0.051 USD. The numbers matter less than the timing. Names are cheap to question before they are printed and expensive to defend after everyone has fallen for them.&lt;/p&gt;

&lt;p&gt;Use that low cost in the right place. Screen the shortlist before design work starts, screen spelling variants before the domain purchase becomes a victory lap, and screen the final name again if the team changes a word or leans into a sound that was not in the earlier favorite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;This part matters, so I will say it without decoration.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A screening reads public USPTO records only. It does not see common law use, state registers, or applications filed after the run finishes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Similarity scores and the signal level are sorting help for research. They are built from counts and comparisons, not legal conclusions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A name with no matches is not cleared. A crowded name is not automatically unusable. The register can inform a decision, but it cannot make the legal decision for you.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Only a qualified trademark attorney can give a clearance opinion. If the name matters, if the spend is real, or if a close live mark sits in your field, that conversation belongs in the process.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This article and the checker are screening and research aids. They are not legal advice and not a substitute for a professional trademark clearance search.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those limits define the case for checking. A first pass exists to catch the obvious and the nearly obvious before money hardens around a name.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is an exact USPTO search enough?&lt;/strong&gt;&lt;br&gt;
No. Exact search is the right first step and the wrong last step. It can miss shorter marks, spelling variants, sound variants, and names built on the same distinctive word. The Nimbus example had 0 live exact matches and still returned 60 live near matches, including a live NIMBUS COFFEE mark at 0.863 similarity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does a MODERATE signal mean?&lt;/strong&gt;&lt;br&gt;
It means the live record field looks crowded enough to deserve a careful read. In the checker, the signal level is research sorting based on record counts and match strength. MODERATE should slow you down and send you back to the records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If there are no live exact matches, can I use the name?&lt;/strong&gt;&lt;br&gt;
Not on that evidence alone. You still need to weigh near matches, related goods and services, common law use, state registers, and the distinctiveness of the name. A clean exact result is a good start. It is not a clearance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does class matter if the names are not identical?&lt;/strong&gt;&lt;br&gt;
Because customers meet brands in related places. A mark for café services and a mark for packaged coffee can feel connected even though they sit in different Nice classes. Class helps you organize the search. Relatedness helps you judge the risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many names should I screen?&lt;/strong&gt;&lt;br&gt;
Screen the real shortlist, not just the favorite. Five to ten finalists is a practical range for many early brands, and variants belong in the pass when a spelling or sound change is on the table. At 0.01 USD per completed screening plus 0.001 USD to start a run, ten completed screenings come to 0.101 USD, so the cost of checking the whole shortlist is rarely the hard part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I talk with a trademark attorney?&lt;/strong&gt;&lt;br&gt;
Talk with one before you file, before a major spend, or when a close live mark shares your distinctive word in the same or a related class. An attorney can judge facts a screening cannot, including use in commerce and the strength of the mark. Screening helps you arrive with better questions and cleaner notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the name while changing it is still cheap
&lt;/h2&gt;

&lt;p&gt;The goal is not fear. The goal is timing. A name problem found at the sticky note stage is a story you tell later with relief. The same problem found after the brand is built becomes a bill, a scramble, and a new name chosen under pressure. Check first, while the name is still young enough to change. If you want the near matches ranked before you read them, run the shortlist through the &lt;a href="https://apify.com/delta_tools/trademark-clearance-checker?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=trademark-name-check-before-brand"&gt;Trademark Clearance Checker&lt;/a&gt; and start from the top of the list.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>startup</category>
      <category>branding</category>
      <category>business</category>
    </item>
    <item>
      <title>Price-Drop Alerts: Monitoring Any Product Page for Changes</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:34:07 +0000</pubDate>
      <link>https://dev.to/deltatools/price-drop-alerts-monitoring-any-product-page-for-changes-3h5m</link>
      <guid>https://dev.to/deltatools/price-drop-alerts-monitoring-any-product-page-for-changes-3h5m</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; You do not need to keep a product page open in a tab and refresh it like it owes you money. There are three ways to get told the moment a price falls. First, use the trackers and saved searches that already exist for big marketplaces. Second, poll any product page with a short Python script that reads the price the store itself embeds in the page, remembers it, and speaks up only when the number goes down. Third, run a scheduled monitor that does the remembering for you. Delta Tools built one for marketplace listings. It is called the OfferUp Fresh Listing + Price Change Monitor, and it watches OfferUp searches and reports each price drop with the old price and the size of the drop included. All three methods are below, with working code for the second one.&lt;/p&gt;

&lt;p&gt;I learned this lesson on a camera lens. The listing sat at $850 for weeks. On a Tuesday morning it fell to $640. By Friday it was back at $810, and it stayed there for another month. I saw none of it, because I checked the page on Monday and again on the weekend, and in my head the price simply was $850. The drop was real. My timing was the problem.&lt;/p&gt;

&lt;p&gt;That pattern repeats everywhere once you start looking. Sellers cut prices quietly. Stores run short sales that never get announced. A marketplace seller who has had no messages in ten days trims $40 off a listing rather than writing a better description. None of these events send you a notification by default. The page just changes, silently, and waits for whoever happens to be watching.&lt;/p&gt;

&lt;p&gt;Here are three ways to be the person who is watching, from simplest to fully automated.&lt;/p&gt;

&lt;p&gt;Before we start: the Delta Tools shortcut for the marketplace version of this problem is our &lt;a href="https://apify.com/delta_tools/offerup-fresh-listing-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=price-drop-alerts-tutorial"&gt;OfferUp Fresh Listing and Price Change Monitor&lt;/a&gt;, which watches your OfferUp searches on a schedule and reports only what changed, including price drops, price increases, new listings, and listings that disappeared. It covers OfferUp rather than every store on the internet, and I will be plain about that again later. The rest of this article teaches you to watch any product page by hand, which is the skill that transfers everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: Use the trackers and saved searches that already exist (free, minutes)
&lt;/h2&gt;

&lt;p&gt;For the biggest marketplaces, somebody has already built the watcher and gives it away. Your job in this method is simply to find the right one and turn it on properly.&lt;/p&gt;

&lt;p&gt;Amazon is the most served store on earth for this. Keepa tracks the price history of Amazon products and can alert you when a price crosses a threshold you set. CamelCamelCamel does the same job with price history charts and drop alerts, and it has done it for years. Both are free for basic use. Both also share the same limit, which is that they only know Amazon. The moment your product lives anywhere else, they know nothing about it.&lt;/p&gt;

&lt;p&gt;Marketplaces reward a different habit. On eBay, run your search with the filters you actually care about, save the search, and turn on the email alerts for it. On OfferUp, save the search in the app and allow notifications. New listings then come to you, and when you open the listing you can see the current asking price. What these saved searches do poorly is history. They tell you a listing exists. They rarely tell you that the same listing was $550 last week and is $480 today, because the marketplace shows you the present and quietly forgets the past. Remembering the past is the entire trick of this article, and it is why Method 2 exists.&lt;/p&gt;

&lt;p&gt;General page watching services sit between those two worlds. Tools such as Visualping and Distill watch a page or a selected part of a page and notify you when the content changes. They work on almost any store, they need no code, and their free tiers are enough for a handful of pages checked at gentle intervals. Their weakness is that they watch everything on the page, not the price specifically. A rotated banner, a new review count, or a seasonal badge can all trigger a change alert that has nothing to do with money. You will read a few false alarms before you learn to select just the price element, and on some stores the price element moves around too much for that to stay reliable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is enough:&lt;/strong&gt; you watch a small number of products, they live on Amazon or on a marketplace you already use, and a history chart plus a threshold alert covers what you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is not:&lt;/strong&gt; the product lives on a store nobody tracks, you want alerts for a drop of a specific size rather than any change at all, or you watch enough pages that sorting real drops from page noise becomes its own chore. Keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Poll any product page with a script you control (free, works everywhere)
&lt;/h2&gt;

&lt;p&gt;A product page knows its own price, and most stores state that price in several places at once, in formats meant for machines. That fact is what makes a small script reliable enough to trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the price actually lives on a page
&lt;/h3&gt;

&lt;p&gt;The first place to look is the structured data block. Many stores embed a script block of type application/ld+json in the page head. Inside it, a product is described with fields search engines use, and an offers section usually carries a price field and a priceCurrency field with values such as USD or EUR. This is the cleanest source on the page because the store wrote it for Google, and stores keep it accurate for the same reason you are reading it. Wrong structured data costs them clicks in search results.&lt;/p&gt;

&lt;p&gt;The second place is the Open Graph tags in the page head. A meta tag with property product:price:amount carries the price, and a companion tag carries the currency. Stores add these so shared links render nicely in chats and social feeds, and they tend to be current because a stale price in a shared link preview is embarrassing in public.&lt;/p&gt;

&lt;p&gt;The third place is microdata in the visible markup. An element with itemprop price, again from the schema.org vocabulary, either holds the price in a content attribute or prints it as text a shopper can read.&lt;/p&gt;

&lt;p&gt;The last resort is the visible text itself. The first dollar amount on a page is often the price. It is also often a trap, because pages print installment amounts, accessory prices, and struck through list prices near the real one. The script below tries the three machine readable sources first and only falls back to visible text when nothing else answers. When you adopt it, check the baseline it records for each page once, by eye, so you know which rung of the ladder each of your pages landed on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The script
&lt;/h3&gt;

&lt;p&gt;The script keeps a watch list of names and page addresses. For each page it fetches the HTML, extracts the price with the ladder above, and compares the result with the price stored in a small JSON state file. A lower price produces an alert line, printed and optionally posted to a webhook so it lands in Slack or Discord or anywhere else that accepts a JSON post. An equal or higher price produces silence, and the new price becomes the reference for the next check.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;STATE_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRICE_WEBHOOK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MIN_DROP_PERCENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="n"&gt;WATCHLIST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;espresso machine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/products/espresso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trail shoes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/products/trailshoes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 price watch script&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;price_from_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;offers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowPrice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;to_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;offer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;price_from_node&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;price_from_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;price_from_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_price_in_jsonld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script[^&amp;gt;]*type=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/ld\+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*&amp;gt;(.*?)&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;price_from_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_price_in_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;property=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product:price:amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([\d][\d,]*\.?[\d]*)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;to_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_price_in_microdata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;itemprop=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([\d][\d,]*\.?[\d]*)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;itemprop=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*&amp;gt;([\d][\d,]*\.?[\d]*)&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;to_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_price_in_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\$\s*([\d][\d,]*\.?[\d]*)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;to_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;finders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;find_price_in_jsonld&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;find_price_in_meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;find_price_in_microdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;find_price_in_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;finder&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;finders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;finder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;title&amp;gt;(.*?)&amp;lt;/title&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;first_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WATCHLIST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: fetch failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: no price found on page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;old_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;
                &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MIN_DROP_PERCENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRICE DROP: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; was $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and is now $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (down &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;percent&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; percent)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;find_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checked_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Baseline built: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; prices recorded, none announced.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run it again later and only genuine drops will print.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Checked &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; pages.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that script deserve a callout, because each one bit me before I learned to handle it on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first run builds a baseline in silence.&lt;/strong&gt; If the script announced every price on its first run, you would learn what things cost today, which you already know, and nothing about what changed. So the first run records and stays quiet. From the second run onward, it speaks only when a number moves down. If you edit the watch list later, expect the new pages to join quietly in the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The comparison is against the last price seen, so set a floor for what counts.&lt;/strong&gt; A seller who trims a price in small steps will trigger an alert at every step, and five alerts about a slow slide from $500 to $460 will teach you to ignore the channel. Set MIN_DROP_PERCENT to 3 or 5 and the script only speaks when a drop is worth your attention. Leave it at zero while you are testing, so you can watch it work on the first small change you can arrange.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The state file is the entire memory, and it is fragile in specific ways.&lt;/strong&gt; It lives on the machine that runs the script. If that machine is asleep at the scheduled hour, that hour simply never happens. If the file is deleted, the baseline resets and the script goes quiet for one run while it relearns the world. If a page starts failing and returns no price, the script skips it and keeps the old reference, which is the behavior you want, because a store hiccup should never read as a drop to zero. None of this is hard to live with. All of it is yours to live with, on your hardware, forever.&lt;/p&gt;

&lt;p&gt;Schedule it with cron on Linux or macOS, or with Task Scheduler on Windows. Hourly checks are a sane default for ordinary shopping. A line like the one below runs the script at the top of every hour and appends its output to a log you can review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 * * * * /usr/bin/python3 /home/you/price_watch.py &amp;gt;&amp;gt; /home/you/price_watch.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be a polite guest while you do this. One request per page per check is a trivial load, and hourly checks on a handful of pages will never trouble a store. Check a page every thirty seconds and you will meet the two status codes that end polite conversations. A 403 means the server understood your request and refuses to answer it. A 429 means you are asking too often, and the correct response is to slow down, not to get clever. Stores also write their rules into their terms, and a personal watcher that fetches a few public pages an hour sits in a very different place from a scraper harvesting a whole catalog. Keep it in the first place.&lt;/p&gt;

&lt;p&gt;Set the PRICE_WEBHOOK_URL environment variable to a Slack or Discord webhook and the printed lines become push alerts in a channel you already read. Leave it unset and the log file is your alert feed, which is honestly enough for anything that is not racing other buyers.&lt;/p&gt;

&lt;p&gt;If you want the middle ground between building this and forgetting it exists, this is where I should mention the Delta Tools option again, because it exists for the exact moment the script starts feeling like a second job: the &lt;a href="https://apify.com/delta_tools/offerup-fresh-listing-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=price-drop-alerts-tutorial"&gt;OfferUp Fresh Listing and Price Change Monitor&lt;/a&gt; runs this same pattern on a schedule for OfferUp searches, with the remembering handled for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: A scheduled monitor for marketplace listings (no infrastructure)
&lt;/h2&gt;

&lt;p&gt;Method 3 is Method 2 with the fragile parts removed, aimed at the place where prices move the most arbitrarily, which is other people and their listings. A store reprices with software and a plan. A person reprices with feelings and a quiet Tuesday. The same couch sits at $300 for a month, then falls to $220 because the seller is moving on Friday. That is the drop worth catching, and it is the drop a saved search alone will never show you, because the marketplace presents the listing as if $220 was always the price.&lt;/p&gt;

&lt;p&gt;The monitor is a small Apify actor. You give it searches, and each search names a keyword, a ZIP code, and a radius, with options such as a maximum price and how many listings to scan. On every scheduled run it fetches the current listings, compares them against a snapshot it keeps between runs, and writes only the changes to its output dataset. Each change row carries a type, and the type matters. A price drop row includes the new price, the old price, and the drop as a percent, so you can judge in one glance whether $480 down from $550 deserves your evening. The same run also reports price increases, new listings, edited listings, and listings that disappeared, and a disappearance usually means the item sold, which is information too. Removal is only reported after a listing misses two runs in a row, so a listing that briefly falls out of the results window does not cry wolf.&lt;/p&gt;

&lt;p&gt;Give each scheduled monitor its own stateKey if you run more than one, and their memories stay separate. One key for the camera gear near your home, another for the furniture search you run for a friend, no cross talk between them. No OfferUp login is needed, and the actor makes its requests against the public search interface at light volume.&lt;/p&gt;

&lt;p&gt;The pricing matches the philosophy of the script. You pay $0.001 when a run starts and $0.005 for each change it reports. A scheduled run that finds nothing new costs the start fee and nothing more. Compare that with scrapers that charge for every row on every run, including the listings you saw yesterday at the same prices. On this design, a quiet week costs you almost nothing, and a busy week costs you pennies in exchange for being early.&lt;/p&gt;

&lt;p&gt;Now the honest scope note, because it decides whether this method is yours. The monitor watches OfferUp. It does not watch a retailer product page, and I will not dress it up as if it does. If the thing you want is a specific product on a specific store site, Method 2 is your tool and it is a good one. If the thing you want is whatever the used market near you is repricing this week, the &lt;a href="https://apify.com/delta_tools/offerup-fresh-listing-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=price-drop-alerts-tutorial"&gt;OfferUp Fresh Listing and Price Change Monitor&lt;/a&gt; is the version where someone else keeps the lights on, and you just read the feed!&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps that make price alerts lie
&lt;/h2&gt;

&lt;p&gt;Every method above can be fooled in the same handful of ways. Knowing them is the difference between an alert feed you trust and one you mute by the end of the month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The price on the page is not always the price you pay.&lt;/strong&gt; Variants are the classic case. One page sells twelve configurations, the structured data reports the cheapest one, and the configuration you want never moves. If a drop alert sends you to a page and the number looks wrong, check which variant the page opened on before you blame the watcher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Location changes prices.&lt;/strong&gt; Stores price by region, by warehouse stock, and sometimes by the shipping address they have guessed from your visit. A script checking from a server in another state can see a different number from the one your browser shows you at home. When a drop matters, confirm it the way you would buy it, signed in and with your own delivery area set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some prices only exist in the cart.&lt;/strong&gt; Coupons applied at checkout, member prices revealed after sign in, and rebates claimed by mail all live below the layer any page watcher can see. Page watching tracks the public price honestly. It cannot track the private arithmetic of a loyalty program, and any tool that promises otherwise is guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing price is not a price of zero.&lt;/strong&gt; When an item sells out, many stores remove the price from the page or replace the offers block entirely. A naive watcher reads that silence as a total collapse and sends the happiest false alert of your week. The script above treats no price found as a skip and keeps the old reference. Whatever you build or adopt, insist on the same behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pages fight back, politely at first.&lt;/strong&gt; A store that does not want automated visits will usually start with slow responses, then a 403, then a challenge page that contains no price at all. Your watcher will report skips, which is the signal to slow down or stop for that store, not to escalate. The public web stays public partly because guests behave like guests.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How often should I check a product page for a price drop?&lt;/strong&gt;&lt;br&gt;
Hourly is plenty for almost everything a person buys. Stores and sellers rarely reprice more often than that in ways that matter, and hourly checks keep your request volume trivial. Daily checks are fine for slow categories such as furniture. Save the aggressive intervals for marketplace listings racing other buyers, and even then, think in hours rather than minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What size of drop should trigger an alert?&lt;/strong&gt;&lt;br&gt;
Start with any drop at all while you learn what normal movement looks like on your pages. After a week or two you will know which of your products jitter by a dollar and which sit frozen for months. Then set a floor, in percent or in plain dollars, that matches what would actually make you buy. An alert you would not act on is noise wearing a costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I watch pages in other currencies with the script?&lt;/strong&gt;&lt;br&gt;
Yes, with one small change. The structured data and meta tag rungs of the ladder read plain numbers and ignore the currency entirely, so they already work for euros, pounds, and yen. Only the visible text fallback looks for a dollar sign. Swap that symbol for the one your store prints and the fallback works there too, and the state file will happily store the numbers either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my watcher see a different price from my browser?&lt;/strong&gt;&lt;br&gt;
Usually location, as covered above, and sometimes a signed in member price or a variant the page selected for you. Fetch the page the way a stranger would and you get the stranger price. Neither number is fake. They are answers to different questions, and your watcher answers the public one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the marketplace monitor cost on a quiet week?&lt;/strong&gt;&lt;br&gt;
Almost nothing, by design. Each run costs $0.001 to start, and each reported change costs $0.005. A daily schedule on a week where nothing changes costs under a cent in start fees. You pay in proportion to how much genuinely changes, which is how monitoring ought to be priced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an account or a login to start with the script?&lt;/strong&gt;&lt;br&gt;
No. The script in Method 2 uses only the Python standard library, runs on any machine that can reach the pages you watch, and stores its memory in a local file you can open and read. The marketplace monitor needs an Apify account to run on a schedule, and no OfferUp login at any point.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Track New Job Postings Across Sites With One Alert Feed</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:06:38 +0000</pubDate>
      <link>https://dev.to/deltatools/how-to-track-new-job-postings-across-sites-with-one-alert-feed-2fng</link>
      <guid>https://dev.to/deltatools/how-to-track-new-job-postings-across-sites-with-one-alert-feed-2fng</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; You do not need to refresh five job boards every morning. There are three ways to get every new posting into one place. First, turn on the saved search email alerts inside each board and funnel them into one inbox folder. Second, poll the official APIs of USAJobs and arbeitnow with a short Python script that remembers what it has already seen and prints only what is new. Third, run a scheduled monitor that does the remembering for you. Delta Tools built one for exactly this job. It is called the Job Board New Posting Monitor, and it watches USAJobs and arbeitnow searches and reports only genuinely new postings. All three methods are below, with working code for the second one.&lt;/p&gt;

&lt;p&gt;I spent one job hunt doing this the stupid way. Five boards. Five tabs. Every morning I ran the same searches, tried to remember which titles I had already seen, and told myself I was being thorough. I was not being thorough. I was being slow. By the time I found one posting I liked, it had been live for four days, and anyone who applied on day one was already ahead of me in the queue.&lt;/p&gt;

&lt;p&gt;Job hunting rewards speed more than almost anyone admits. Recruiters start reading applications while a posting is still gathering them. Some roles close within a week. The edge is not a better resume template. The edge is simply knowing a role exists on the morning it appears, not the morning you happen to check.&lt;/p&gt;

&lt;p&gt;Here are three ways to get that edge, from simplest to fully automated.&lt;/p&gt;

&lt;p&gt;Before we start: the Delta Tools shortcut for all of this is our &lt;a href="https://apify.com/delta_tools/job-board-new-posting-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=job-posting-alerts-tutorial"&gt;Job Board New Posting Monitor&lt;/a&gt;, which watches up to 20 searches at once on a schedule and reports only postings it has never shown you before. The rest of this article teaches you to build the same thing by hand, in case you would rather run your own setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: Turn on the alerts each board already gives you (15 minutes, free)
&lt;/h2&gt;

&lt;p&gt;Almost every large board has the same feature hiding under a slightly different name. You run a search, you save it, and the board emails you when new postings match. Most job seekers never turn it on. They bookmark the search results page instead, which is the digital equivalent of walking to the shop every day to see if the bread arrived.&lt;/p&gt;

&lt;p&gt;USAJobs is the clearest example, and it is worth doing properly because federal roles have hard closing dates. Sign in, run your search with your keywords and location, and click &lt;strong&gt;Save this search&lt;/strong&gt; above the results. Give it a name and choose how often you want email. Daily is the right choice for anything competitive. USAJobs lets you keep up to 10 saved searches at once, and a saved search expires after one year, so put a note in your calendar to renew the ones that still matter. The notification emails come from &lt;a href="mailto:notifications@usajobs.gov"&gt;notifications@usajobs.gov&lt;/a&gt;, and they only list jobs posted since your last email, which means the board is doing the remembering for you on its own turf.&lt;/p&gt;

&lt;p&gt;LinkedIn and Indeed offer the same pattern. Run the search, set the filters, and switch on the email alert. The names of the buttons differ. The idea does not.&lt;/p&gt;

&lt;p&gt;Now for the part that turns five alert streams into one feed. Create one folder or label in your inbox called Job Alerts. Add a filter rule for each sender address so every alert lands in that folder instead of scattering across your inbox. USAJobs sends from the address above. Indeed and LinkedIn send from their own alert addresses, and you can capture each one from the first email it sends you. From then on, your morning routine is one folder, read once, in date order. Everything new is in there. Nothing requires you to remember what you saw yesterday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is enough:&lt;/strong&gt; you follow a handful of searches, the boards you care about all have decent alert emails, and you actually read email every morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is not:&lt;/strong&gt; the board you need has weak alerts or none at all, you want alerts inside Slack or Discord instead of email, or you follow enough searches that the daily folder becomes its own chore. Keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Poll the official APIs with a script (free, one feed you control)
&lt;/h2&gt;

&lt;p&gt;Two of the most useful boards publish official APIs, and they could not be more different in spirit.&lt;/p&gt;

&lt;p&gt;USAJobs runs the official jobs board of the United States federal government. Its API at data.usajobs.gov is free, but it wants a personal API key. You request one on the site, and every request sends that key in a header called Authorization-Key, plus a User-Agent header set to your email address. In return you get structured data with a gift inside it: every posting carries a stable PositionID that never changes for the life of that posting.&lt;/p&gt;

&lt;p&gt;arbeitnow is a European board with a large share of English language postings, many of them open to remote work. Its job board API is free and needs no key at all. One GET request to its public endpoint returns a batch of recent postings as JSON, wrapped in a field called data, and you page through older batches by adding a page parameter. It has no search parameter on the server side, so filtering happens on your own machine: you fetch a few pages and keep the postings that match your keyword. Every posting carries a slug, the unique tail of its URL, and that slug is its stable identity.&lt;/p&gt;

&lt;p&gt;Stable identity is the whole game. Here is why. You cannot detect new postings by watching the top of a list, because lists move. A posting that sat at position three yesterday sits at position nine today, and a board may edit a title or extend a closing date and push an old posting back into view. If your script compares positions or titles, it will cry wolf constantly, and you will stop reading its alerts within a week. If it compares stable IDs against a set of IDs it has already reported, it stays silent until something genuinely new appears. Dedup is the whole product. Everything else is plumbing.&lt;/p&gt;

&lt;p&gt;Here is the whole thing in one script. It checks three pages of arbeitnow and one USAJobs search, prints every posting it has never seen before, and optionally posts each one to a webhook so the alert lands in Slack or Discord or anywhere else that accepts a JSON post.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seen_jobs.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JOB_WEBHOOK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;USAJOBS_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USAJOBS_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;USAJOBS_EMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USAJOBS_EMAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ARBEITNOW_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.arbeitnow.com/api/job-board-api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;USAJOBS_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://data.usajobs.gov/api/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_arbeitnow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;needle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pages&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="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ARBEITNOW_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?page=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
            &lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;needle&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arbeitnow:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_usajobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;USAJOBS_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USAJobs skipped: no API key set.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Keyword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ResultsPerPage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;USAJOBS_API&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data.usajobs.gov&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;USAJOBS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;USAJOBS_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SearchResultItems&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MatchedObjectDescriptor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;job_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PositionID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MatchedObjectId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usajobs:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PositionTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OrganizationName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PositionLocationDisplay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PositionURI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NEW: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;first_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_arbeitnow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;software engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fetch_usajobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data analyst&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;new_jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Baseline built: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; postings recorded, none announced.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run it again tomorrow and only genuinely new postings will print.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Checked &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; postings, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; new.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that script deserve a callout, because each one cost me a round of duplicate alerts before I learned it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first run builds a baseline in silence.&lt;/strong&gt; If the script announced everything on its first run, you would get a flood of postings that have been sitting there for weeks, and half of them would already be closed to serious competition. So on the first run it records what exists and announces nothing. From the second run onward, it announces only IDs it has never recorded. If you would rather see the full list once, announce on the first run too. Just know what you are asking for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seen file is the entire database, and it is fragile in specific ways.&lt;/strong&gt; It lives on the machine that runs the script. If that machine is asleep at the scheduled hour, that hour simply never happens. If the file gets deleted, the baseline resets and the flood returns. If the script crashes after announcing a posting but before saving the file, you will hear about that posting twice. None of this is hard to fix. All of it is yours to fix, forever, at 11 p.m. on a Sunday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyword matching on arbeitnow is honest but blunt.&lt;/strong&gt; Because the API has no server side search, the script fetches recent pages and matches your keyword against the title, the company name, and the tags. Postings that describe your role with a synonym you did not pick will slip past. Run two or three keyword variants if the role has more than one common name. And keep the page count modest. arbeitnow gives this API away for free and asks callers not to abuse it, and three pages every hour is well inside polite territory.&lt;/p&gt;

&lt;p&gt;Schedule it with cron on Linux or macOS, or with Task Scheduler on Windows, every hour or two during the hours you care about. Set the JOB_WEBHOOK_URL environment variable to a Slack or Discord webhook and the prints become push alerts in a channel you already read. Set USAJOBS_API_KEY and USAJOBS_EMAIL and the federal postings join the same feed. One feed. One place to look.&lt;/p&gt;

&lt;p&gt;If you want the middle ground between Methods 1 and 2, this is also where I should mention the Delta Tools option again, because it exists for the exact moment the script starts feeling like a second job: the &lt;a href="https://apify.com/delta_tools/job-board-new-posting-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=job-posting-alerts-tutorial"&gt;Job Board New Posting Monitor&lt;/a&gt; runs this same pattern on a schedule, with the remembering handled for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: A scheduled monitor that handles the boring parts (no infrastructure)
&lt;/h2&gt;

&lt;p&gt;Method 3 is Method 2 with the fragile parts removed. The monitor is a small Apify actor. You give it your searches, up to 20 in a single run, and each search names a source, USAJobs or arbeitnow, plus a keyword and its own options. USAJobs searches accept a location and a freshness window, so your first run can ignore postings older than seven days instead of drowning in history. arbeitnow searches accept a location filter and a remote only switch. You paste in your free USAJobs key if you use federal searches. arbeitnow needs no key at all, and if you leave the key out, the USAJobs searches skip gracefully while the arbeitnow searches still run.&lt;/p&gt;

&lt;p&gt;On every scheduled run it fetches the current postings, compares stable IDs against a snapshot it keeps between runs, and writes only the genuinely new ones to its output dataset. Each row carries the title, the company, the location, the posting date, and a direct link, tagged with the search that found it. It can also report postings that disappeared, which matters more than it sounds: a posting vanishing inside its window usually means the role was filled or pulled, and knowing that stops you polishing an application for a door that already closed. Rows about removed postings are never billed.&lt;/p&gt;

&lt;p&gt;The pricing matches the philosophy. You pay $0.001 when a run starts and $0.0015 for each genuinely new posting it delivers. A scheduled run that finds nothing new costs the start fee and nothing more. Compare that with scrapers that charge for every row on every run, including the ninety percent of rows you saw yesterday. On this design, a quiet week costs you almost nothing, and a busy week costs you pennies in exchange for being early.&lt;/p&gt;

&lt;p&gt;The output dataset plugs into the rest of your life through Apify integrations and webhooks, so new postings can land in Slack, in email, or in a Google Sheet you share with a friend who is hunting for the same roles. Give each scheduled monitor its own stateKey if you run more than one, and their memories stay separate. One key for your own search, another for the contract roles you track for a client, no cross talk between them.&lt;/p&gt;

&lt;p&gt;I will not pretend the script in Method 2 is a lesser choice. It is free, it is transparent, and the code above is yours to keep and change. Plenty of people should run the script. The actor exists for people who have noticed that their laptop sleeps, their cron file does not survive a new machine, and their seen file is one careless cleanup away from a Monday morning flood. The &lt;a href="https://apify.com/delta_tools/job-board-new-posting-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=job-posting-alerts-tutorial"&gt;Job Board New Posting Monitor&lt;/a&gt; is the version where someone else keeps the lights on, and you just read the feed!&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How often should I check for new postings?&lt;/strong&gt;&lt;br&gt;
Hourly during waking hours is plenty for most roles. Federal postings on USAJobs usually stay open for days or weeks, so even a daily check beats the crowd there. Competitive commercial roles can gather a full slate of applicants fast, and for those, hourly checks on a schedule beat a daily email digest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I watch more than one board in a single feed?&lt;/strong&gt;&lt;br&gt;
Yes, and that is the point of this article. Method 1 funnels the alert emails of several boards into one inbox folder. Method 2 merges arbeitnow and USAJobs into one printed or webhooked feed. Method 3 runs up to 20 searches across both sources in a single scheduled run and merges the results into one dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just scrape LinkedIn or Indeed?&lt;/strong&gt;&lt;br&gt;
Because you do not need to, and the ground there is genuinely hostile. Both platforms work hard to block automated collection, and accounts get restricted for it. The boards in this article publish official APIs that hand you the same postings in structured form, with stable IDs made for exactly this job. Use the door the owner built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does new actually mean in these methods?&lt;/strong&gt;&lt;br&gt;
It means an ID your system has never recorded before. Not a posting near the top of a list. Not a posting with an edited title. A stable posting ID, a PositionID on USAJobs or a slug on arbeitnow, appearing for the first time in your snapshot. That definition is what keeps the feed quiet on ordinary days and loud only when it should be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does the monitor cost on a quiet week?&lt;/strong&gt;&lt;br&gt;
Almost nothing, by design. Each run costs $0.001 to start, and new postings cost $0.0015 each. A daily schedule on a week where nothing matches costs under a cent in start fees. You pay in proportion to how much genuinely new material shows up, which is how monitoring ought to be priced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key to start?&lt;/strong&gt;&lt;br&gt;
Only for USAJobs, and that key is free from data.usajobs.gov. arbeitnow needs no key, no account, and no setup, so you can run the script in Method 2 against arbeitnow in the next five minutes and add the federal feed whenever you are ready.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Monitor SAM.gov for New Contract Opportunities Without Checking Daily</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:31:04 +0000</pubDate>
      <link>https://dev.to/deltatools/how-to-monitor-samgov-for-new-contract-opportunities-without-checking-daily-5f7n</link>
      <guid>https://dev.to/deltatools/how-to-monitor-samgov-for-new-contract-opportunities-without-checking-daily-5f7n</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A Congressional Research Service report states that when a federal contract is expected to exceed $25,000, the agency posts the solicitation on SAM.gov. There are three ways to watch those postings without refreshing the site every morning: SAM.gov's own saved search email alerts (free, but thin), a Python script that polls SAM.gov's public search API (free, real routing control), or a scheduled monitor that handles dedup and alerting with no infrastructure (&lt;a href="https://apify.com/delta_tools/samgov-new-opportunity-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=samgov-opportunity-alerts-tutorial"&gt;our SAM.gov New-Opportunity Monitor&lt;/a&gt; is built for this). All three methods are below, with working code.&lt;/p&gt;




&lt;p&gt;I once watched a small IT services firm lose a bid they were perfect for because someone found the solicitation with three days left on the response window. Not three days after it posted. Three days before responses were due. The notice had been sitting on SAM.gov for six weeks.&lt;/p&gt;

&lt;p&gt;That is the entire problem in one story. The federal government posts thousands of contract opportunities, and the difference between winning and never knowing a contract existed is usually just timing. Small contractors and boutique govcon shops cannot afford a full time person refreshing SAM.gov every morning. So they check when they remember, which means they miss things.&lt;/p&gt;

&lt;p&gt;Here are three ways to fix that, from simplest to fully automated.&lt;/p&gt;

&lt;p&gt;Before we start: the Delta Tools shortcut for all of this is our &lt;a href="https://apify.com/delta_tools/samgov-new-opportunity-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=samgov-opportunity-alerts-tutorial"&gt;SAM.gov New-Opportunity Monitor&lt;/a&gt;, which watches keyword searches on a schedule and reports only genuinely new notices. The rest of this article teaches you to do the same thing by hand, in case you would rather run your own setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: SAM.gov saved searches with email alerts (10 minutes, free)
&lt;/h2&gt;

&lt;p&gt;This is the feature most people walk right past. SAM.gov has saved searches, and saved searches for Contract Opportunities have a Notify option that emails you when new matches appear. This is documented in the GSA Federal Service Desk knowledge base, and it is the only SAM.gov domain where the notify feature exists.&lt;/p&gt;

&lt;p&gt;Here is how to set it up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in to SAM.gov (you need an account for this; anonymous visitors can search but cannot save).&lt;/li&gt;
&lt;li&gt;Run a Contract Opportunities search for your keywords.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Actions&lt;/strong&gt; button in the upper right, then &lt;strong&gt;Save&lt;/strong&gt;. Give the search a name.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Saved Searches&lt;/strong&gt; (from the search results page or your Workspace).&lt;/li&gt;
&lt;li&gt;Click the Actions menu next to your saved search and select &lt;strong&gt;Notify&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is it. On any day your search has new results, SAM.gov sends an email to your registered address. An Army Corps of Engineers guidance document notes these notifications go out once daily at 9 p.m. Eastern.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, the email is just a link to your saved search. It does not tell you what matched, how many notices matched, or whether any of them are worth your time. You click, you log in, you read. Second, the daily cadence means a solicitation with a short response window can burn most of its fuse before you see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is enough:&lt;/strong&gt; you have one or two keyword searches, you check email every morning anyway, and your targets are mostly Solicitations and Combined Synopsis/Solicitations with normal response windows. For a lot of small firms, this genuinely covers it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is not:&lt;/strong&gt; you want routing control (Slack, a shared inbox, a webhook into your CRM), you want alerts faster than once a day, or you watch enough searches that the daily link click becomes its own chore. Keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Poll the public search API with a script (free, real control)
&lt;/h2&gt;

&lt;p&gt;SAM.gov's search page is powered by a public, unauthenticated search API. No API key, no login, no proxy. The endpoint looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sam.gov/api/prod/sgs/v1/search/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes query parameters. The ones that matter: &lt;code&gt;index=opp&lt;/code&gt; (the Contract Opportunities index), &lt;code&gt;q&lt;/code&gt; (your keyword), &lt;code&gt;qMode=ALL&lt;/code&gt; (mandatory, without it the keyword is silently ignored), &lt;code&gt;size&lt;/code&gt; (how many results, between 10 and 1000), and &lt;code&gt;sort=-modifiedDate&lt;/code&gt; (newest first).&lt;/p&gt;

&lt;p&gt;A quick warning before the code, because this is the kind of thing that bites people. The sort is by &lt;strong&gt;modified date&lt;/strong&gt;, not publish date. A notice amended this morning sorts as "newest" even if it was originally published four months ago. So you cannot diff by position or by date. You must diff by notice ID. Dedup is the whole product here, same as it is in every monitor I have ever built.&lt;/p&gt;

&lt;p&gt;Here is the whole thing in about 40 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://sam.gov/api/prod/sgs/v1/search/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;samgov_seen_ids.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (Windows NT 10.0; Win64; x64)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;opp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qMode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-modifiedDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cybersecurity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_embedded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;new_notices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;notice_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;notice_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;notice_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;new_notices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;notice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solicitation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solicitationNumber&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://sam.gov/opp/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;notice_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_notices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NEW: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;solicitation&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# send_to_slack(f"New SAM.gov opportunity: {n['title']}\n{n['url']}")
&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Checked &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; notices, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_notices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; new.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a cron every hour or two. The &lt;code&gt;samgov_seen_ids.json&lt;/code&gt; file is your entire database. Wire the &lt;code&gt;send_to_slack&lt;/code&gt; line to Slack, Discord, email, or an SMS gateway and you have real push alerts routed wherever your team actually looks.&lt;/p&gt;

&lt;p&gt;A few things worth knowing before you scale this up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch Sources Sought notices, not just Solicitations.&lt;/strong&gt; SAM.gov notice types include Solicitation (o), Combined Synopsis/Solicitation (k), Presolicitation (p), Sources Sought (r), Special Notice (s), and Award Notice (a). Sources Sought notices are presolicitation market research. Responding early with a capability statement puts you on the agency's radar before the formal solicitation exists, and most of your competitors will never know the opportunity was brewing. Several govcon coaches specifically advise filtering for these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not trust server side NAICS filtering on this API.&lt;/strong&gt; In testing, NAICS and set aside parameters on the frontend search API silently fail open, meaning you get results outside your filter without any error. Keyword search is fully server side and reliable; notice type filtering is safest done client side after you fetch. And remember that contracting officers assign NAICS codes inconsistently. A well known example in govcon circles: an IT contract might land under 541512 one week and 541519 the next. Search adjacent codes or just use broad keywords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The script's real fragility is operational, not technical.&lt;/strong&gt; One search is easy. Ten searches means ten state files, ten cron entries, and ten chances for a crash between "detect new notice" and "save state" to produce either duplicate alerts or a missed one. Move machines and the state files do not come with you. I built method 3 because I got tired of babysitting exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: A scheduled monitor that handles the boring parts (no infrastructure)
&lt;/h2&gt;

&lt;p&gt;This is the version I run now: a small Apify actor that watches SAM.gov keyword searches on a schedule and reports only genuinely new notices. You configure up to 20 searches per run, each with a keyword, an active only toggle, a result cap, and optional notice type filters. On each run it fetches the newest results, diffs by notice ID against a persisted snapshot, and outputs only notices it has never seen before. Amendments to notices you have already seen are not reported again.&lt;/p&gt;

&lt;p&gt;The output is a structured dataset per new notice: title, solicitation number, notice type, agency, office, publish date, response due date, and a direct &lt;code&gt;sam.gov/opp/&amp;lt;id&amp;gt;/view&lt;/code&gt; link. New results land in an Apify dataset, which you can wire to Slack, email, Google Sheets, or webhooks through Apify's integrations. A scheduled check that finds nothing new outputs an empty dataset and costs almost nothing.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apify.com/delta_tools/samgov-new-opportunity-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=samgov-opportunity-alerts-tutorial"&gt;SAM.gov New-Opportunity Monitor&lt;/a&gt; is priced per new opportunity: $0.001 per run start plus $0.0025 per genuinely new notice reported. A quiet daily run costs a fraction of a cent. You pay when there is something worth your attention, not for every notice scanned.&lt;/p&gt;

&lt;p&gt;I will not pretend it is the only way to do this. Method 2 is genuinely fine if you like running your own infrastructure, and the code above is yours to keep. The actor exists for people who would rather set a schedule once and get pinged, with no cron jobs, no state files, and no "the laptop was asleep" gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which method should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One or two searches and you live in email?&lt;/strong&gt; Method 1. Saved searches with Notify, ten minutes, free, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want push alerts and routing control and do not mind a little ops?&lt;/strong&gt; Method 2. The script above, a cron job, somewhere always on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want alerts with zero infrastructure and clean dedup?&lt;/strong&gt; Method 3. Set it once, get pinged only when something is actually new.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need a SAM.gov account?&lt;/strong&gt;&lt;br&gt;
Only for method 1. Saved searches and the Notify feature require a login. Methods 2 and 3 use the public search API, which needs no key and no login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How fast are the alerts?&lt;/strong&gt;&lt;br&gt;
Method 1 sends one email per day on days your saved search has new results. Method 2 runs at whatever interval your cron uses; hourly or every two hours is polite and more than fast enough. Method 3 runs on your Apify schedule, hourly or daily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will SAM.gov block the API polling?&lt;/strong&gt;&lt;br&gt;
At reasonable intervals, no. The frontend API powers SAM.gov's own search box, and a query every hour or two is nothing compared to normal site traffic. Do not poll every minute from multiple IPs; that is how you get rate limited anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this catch amendments to notices I already saw?&lt;/strong&gt;&lt;br&gt;
By design, no. The API sorts by modified date, so an amended notice looks "newest," but both the script and the actor diff by notice ID, which means each notice is reported exactly once. If you need amendment tracking, use SAM.gov's Follow feature on individual notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I watch Award Notices too?&lt;/strong&gt;&lt;br&gt;
Yes, as competitive intelligence. Award notices tell you who won what and at what price, which feeds your pricing strategy for the next round. They also reveal which agencies are actively buying what you sell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I watch multiple keywords at once?&lt;/strong&gt;&lt;br&gt;
Method 1: one saved search per keyword set. Method 2: loop the &lt;code&gt;search()&lt;/code&gt; function over a list of keywords and keep one state file per keyword (or namespace the IDs). Method 3: the input takes up to 20 searches in a single run.&lt;/p&gt;




&lt;p&gt;If you would rather not babysit a script, the &lt;a href="https://apify.com/delta_tools/samgov-new-opportunity-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=samgov-opportunity-alerts-tutorial"&gt;SAM.gov New-Opportunity Monitor&lt;/a&gt; is method 2 as an actor: up to 20 keyword searches on a schedule, routed to Slack or email, and you only pay when it finds something genuinely new. Set it once and stop worrying about the 9 p.m. email lottery!&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Substack New-Post Monitor (and the Dedup Problem Nobody Warns You About)</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:33:16 +0000</pubDate>
      <link>https://dev.to/deltatools/how-i-built-a-substack-new-post-monitor-and-the-dedup-problem-nobody-warns-you-about-29cg</link>
      <guid>https://dev.to/deltatools/how-i-built-a-substack-new-post-monitor-and-the-dedup-problem-nobody-warns-you-about-29cg</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Fetching a Substack publication's latest posts took an afternoon. Making the monitor &lt;em&gt;not alert twice on the same post&lt;/em&gt; — across runs, crashes, and machine moves — was the actual project. Here's the naive version, the three ways it broke, and the dedup design that finally held. The finished tool is our &lt;a href="https://apify.com/delta_tools/substack-new-post-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=substack-how-i-built"&gt;Substack New-Post Monitor&lt;/a&gt; on the Apify Store.&lt;/p&gt;




&lt;h2&gt;
  
  
  The naive version (afternoon one)
&lt;/h2&gt;

&lt;p&gt;The job sounded trivial: watch a Substack publication, tell me when a new post appears. Substack even hands you the data — every publication has an RSS feed at &lt;code&gt;&amp;lt;publication&amp;gt;.substack.com/feed&lt;/code&gt;. Parse it, compare against last time, alert on the difference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;feedparser&lt;/span&gt;

&lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feedparser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.substack.com/feed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Latest: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done, right? It printed the latest post. I wired it to a Slack webhook, set a 15-minute cron, and felt extremely productive for about a day.&lt;/p&gt;

&lt;p&gt;Then the double alerts started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #1: "What did I already see?" has no obvious home
&lt;/h2&gt;

&lt;p&gt;The first version kept &lt;code&gt;seen_ids.json&lt;/code&gt; next to the script. It worked until I moved the script to a different machine and every post re-alerted — the state file didn't come along. Then I forgot the file on a deploy and got a fun burst of "NEW POST" messages for articles from 2023.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;the state file is the product, and it needs to live where the compute lives.&lt;/strong&gt; On Apify, that meant a named key-value store shared across scheduled runs — state travels with the actor, not with my laptop. Anywhere else, it means a real database or at minimum a state file on the same always-on host as the cron job, backed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #2: The crash between "detect" and "save"
&lt;/h2&gt;

&lt;p&gt;This one was subtler. The original loop was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch feed&lt;/li&gt;
&lt;li&gt;Diff against seen IDs&lt;/li&gt;
&lt;li&gt;Send alerts for new posts&lt;/li&gt;
&lt;li&gt;Save updated seen IDs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the process died between 3 and 4 — machine reboot, OOM kill, deploy — the next run re-detected the same posts and re-alerted. I "fixed" it by saving state &lt;em&gt;before&lt;/em&gt; sending alerts, which flipped the failure mode: crash between save and alert meant &lt;strong&gt;silently missing posts&lt;/strong&gt;, which is worse. A missed alert is invisible; a duplicate is just annoying.&lt;/p&gt;

&lt;p&gt;The correct order is a tiny transaction: fetch → diff → &lt;strong&gt;persist the new seen-set first&lt;/strong&gt; → send alerts → if alerting fails, the next run sees the posts as already-seen... no wait, that drops them again.&lt;/p&gt;

&lt;p&gt;What actually works: persist seen IDs &lt;em&gt;and&lt;/em&gt; a pending-alerts queue atomically, then drain the queue. On startup, drain any leftover queue before doing new work. It's a miniature outbox pattern, and yes, it's overkill for a side project — until the one time your 3am deploy eats the alert for the post you were actually waiting for.&lt;/p&gt;

&lt;p&gt;For the Apify version, the platform's dataset + key-value store gives atomic-ish primitives that make this straightforward. The principle is the same anywhere: &lt;strong&gt;never let "I noticed it" and "I told someone" be separable without a recovery path.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #3: Identity is harder than it looks
&lt;/h2&gt;

&lt;p&gt;What identifies "the same post" across polls? My first attempt used the post URL. Fine — until Substack's feed briefly served a post with a different canonical URL during an update, and it re-alerted. Then I tried titles; Substack authors edit titles, and every edit re-alerted.&lt;/p&gt;

&lt;p&gt;RSS entries have GUIDs (&lt;code&gt;entry.id&lt;/code&gt; in feedparser), which are &lt;em&gt;supposed&lt;/em&gt; to be stable identifiers. They mostly are. My prototype scheme: &lt;strong&gt;GUID primary, URL as fallback, and never the title.&lt;/strong&gt; Two identifiers agreeing beats one identifier you trust.&lt;/p&gt;

&lt;p&gt;The shipped version simplified this further: Substack's archive API hands you stable post IDs, so the dedup key is just the post ID. Same principle, less machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the finished actor does
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/delta_tools/substack-new-post-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=substack-how-i-built"&gt;Substack New-Post Monitor&lt;/a&gt; on the Apify Store is this design, packaged:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You give it a publication URL and a schedule. It fetches via Substack's public archive API, dedups on Substack's stable post IDs, persists its snapshot in a named key-value store (shared across scheduled runs — the lesson from Break #1, applied), and emits only genuinely new posts to the output dataset.&lt;/li&gt;
&lt;li&gt;New posts land in the dataset ready to wire to alerts via Apify webhooks; a check that finds nothing costs a fraction of a cent.&lt;/li&gt;
&lt;li&gt;It passed 7/7 of my stress tests: duplicate suppression across runs, crash recovery, multi-publication state isolation, feed hiccups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm not claiming it's the only way to do this. The 20-line script from my &lt;a href="https://dev.to/deltatools/how-to-get-notified-when-a-substack-publication-posts-something-new-319a"&gt;alerts tutorial&lt;/a&gt; plus a &lt;code&gt;seen_ids.json&lt;/code&gt; on a Raspberry Pi is a completely legitimate setup — I ran exactly that for weeks. The actor is for when you'd rather not be the ops team for your own alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;Every "monitor X for changes" tool is two tools: a &lt;strong&gt;fetcher&lt;/strong&gt; (afternoon) and a &lt;strong&gt;rememberer&lt;/strong&gt; (the actual project). The fetcher is the demo. The rememberer is the product. If you're building any kind of change-detection — price trackers, job alert bots, listing monitors — budget your time accordingly: 20% fetching, 80% state, identity, and crash recovery.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not just use Substack's email subscriptions?&lt;/strong&gt;&lt;br&gt;
They work, but they're inbox-bound and all-or-nothing per publication. A monitor routes to Slack/Discord/webhooks and watches without subscribing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it handle paid publications?&lt;/strong&gt;&lt;br&gt;
Only public/free posts via the public feed — same limitation as any RSS-based approach. Paywalled content needs an authenticated session, which is a different tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does it cost to run?&lt;/strong&gt;&lt;br&gt;
On Apify's pricing: a scheduled check that finds nothing costs a fraction of a cent. You pay meaningfully only when there's actually a new post to report. (Exact numbers depend on Apify's current compute pricing.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could I build this myself?&lt;/strong&gt;&lt;br&gt;
Absolutely — the tutorial version is 20 lines plus a state file. Build it if you enjoy the ops; use the actor if you don't.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>How to Get Notified When a Substack Publication Posts Something New</title>
      <dc:creator>Delta Tools</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:30:36 +0000</pubDate>
      <link>https://dev.to/deltatools/how-to-get-notified-when-a-substack-publication-posts-something-new-319a</link>
      <guid>https://dev.to/deltatools/how-to-get-notified-when-a-substack-publication-posts-something-new-319a</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Every Substack publication has a hidden RSS feed at &lt;code&gt;publicationname.substack.com/feed&lt;/code&gt;. You can watch it with an RSS reader, poll it with a 20-line Python script, or — if you want alerts without running infrastructure — use a scheduled monitor like our &lt;a href="https://apify.com/delta_tools/substack-new-post-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=substack-alerts-tutorial"&gt;Substack New-Post Monitor&lt;/a&gt;. Below are all three methods, working code included.&lt;/p&gt;




&lt;p&gt;I follow about a dozen Substack publications. For months my system was "remember to check them," which is to say I missed half of everything. Substack has email subscriptions, but I didn't want a dozen newsletters clogging my inbox — I wanted a ping only when something new went live, routed where I actually look.&lt;/p&gt;

&lt;p&gt;There are three solid ways to do this. They go from simplest to most automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: The hidden RSS feed + a reader (2 minutes, free)
&lt;/h2&gt;

&lt;p&gt;This is the thing most people don't know: &lt;strong&gt;every Substack publication exposes an RSS feed&lt;/strong&gt; at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;publication&amp;gt;.substack.com/feed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if you follow &lt;code&gt;stratechery.substack.com&lt;/code&gt;, the feed is &lt;code&gt;https://stratechery.substack.com/feed&lt;/code&gt;. It contains the latest posts with titles, links, and timestamps. It just works — no API key, no scraping.&lt;/p&gt;

&lt;p&gt;Point any RSS reader at it (Feedly, Inoreader, NetNewsWire, even an old-school reader). You'll see new posts alongside everything else you follow. Zero code, zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downside:&lt;/strong&gt; it's pull, not push. You see new posts when you open the reader, not the moment they go live. If "the moment it happens" matters — job alerts, deal alerts, time-sensitive analysis — keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Poll the feed with a script (20 lines of Python)
&lt;/h2&gt;

&lt;p&gt;If you want actual notifications (Slack, Discord, email, SMS), poll the feed on a schedule and diff against what you've already seen. Here's the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;feedparser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;FEED_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.substack.com/feed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seen_ids.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feedparser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FEED_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;new_posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NEW: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# send_to_slack(f"New post: {post.title}\n{post.link}")
&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEEN_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a cron every 15 minutes. The &lt;code&gt;seen_ids.json&lt;/code&gt; file is the entire "database" — and it's also the entire point. &lt;strong&gt;Dedup is the whole product.&lt;/strong&gt; Without remembering what you've already seen, every poll re-alerts on the same posts. With it, you get exactly one alert per new post.&lt;/p&gt;

&lt;p&gt;This works fine for one or two publications on a machine that's always on (a Raspberry Pi, a cheap VPS, or just your laptop if you don't mind it sleeping through alerts).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downsides at scale:&lt;/strong&gt; run it for ten publications and you're managing ten cron jobs and ten state files. Move machines and the state file doesn't come with you — hello, duplicate alerts for everything. And if the script crashes between "detect" and "save state," you either miss posts or double-alert. I learned all of this the annoying way, which is why method 3 exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: A scheduled monitor that handles the boring parts (no infrastructure)
&lt;/h2&gt;

&lt;p&gt;This is what I ended up building: a small Apify actor that watches a Substack publication and reports new posts. You give it the publication URL and a schedule. New posts land in the output dataset, which you can wire to Slack, email, or webhooks through Apify's integrations — no cron jobs or state files to babysit. A check that finds nothing new costs a fraction of a cent.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apify.com/delta_tools/substack-new-post-monitor?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=substack-alerts-tutorial"&gt;Substack New-Post Monitor&lt;/a&gt; is the one I run myself. One job per tool, priced per result — a scheduled check that finds nothing new costs almost nothing.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend it's the only option. If you like running your own infra, method 2 is genuinely fine and the code above is yours to keep. The actor exists for people who'd rather not babysit cron jobs and state files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which method should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Just want to read new posts in one place?&lt;/strong&gt; Method 1. RSS reader, done in two minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want push alerts and don't mind a tiny bit of ops?&lt;/strong&gt; Method 2. The script above, a cron job, somewhere always-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want alerts with zero infrastructure?&lt;/strong&gt; Method 3. Set it once, get pinged.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Does this work for paid Substack publications?&lt;/strong&gt;&lt;br&gt;
The public &lt;code&gt;/feed&lt;/code&gt; only includes free posts. Paid-only content behind the paywall isn't in the public feed — for that you'd need to be subscribed and logged in, which is a different (harder) problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How fast are the alerts?&lt;/strong&gt;&lt;br&gt;
Method 1 is whenever you open your reader. Method 2 is your cron interval (15 minutes is polite; don't hammer it every 60 seconds). Method 3 runs on whatever schedule you set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will Substack block polling?&lt;/strong&gt;&lt;br&gt;
At reasonable intervals (every 10–15 minutes), no. It's a standard RSS feed designed to be polled. Don't poll every minute from five IPs — that's how you get rate-limited anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I watch multiple publications at once?&lt;/strong&gt;&lt;br&gt;
Method 1: add multiple feeds to the reader. Method 2: loop over a list of feed URLs (one state file per publication, or namespace the IDs). Method 3: the actor's input takes a list of publications in a single run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Substack's own email notifications?&lt;/strong&gt;&lt;br&gt;
They work, but they're all-or-nothing per publication and they land in your inbox mixed with everything else. These methods give you routing control — Slack, Discord, webhook, SMS — and let you watch publications without subscribing to their emails.&lt;/p&gt;

</description>
      <category>python</category>
      <category>rss</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
