Notifio scrapes a rental search page every thirty seconds, compares it with what the page held last time, and alerts on anything that was not there before. One function, in principle:
const newListings = findNewListings(previous, current);
In practice, the diff is about ten lines and the code around it is about a hundred. All of that hundred exists for one job: deciding when a difference is not news.
That ratio surprised me, and it is the thing I would tell anyone building change detection against a website they do not own. The comparison is easy. Knowing when not to trust it is the product.
The failure mode that actually hurts
There are two ways to get this wrong and they are not symmetrical.
A missed listing costs the user one room. Annoying, and they will probably never know.
A false alert costs the user their trust in every future alert. The app exists to make a phone buzz mean "go and reply right now". Buzz twice for nothing and the user has learned, correctly, that the buzz is not worth getting up for. At that point the app is uninstalled whether or not it is still running.
So every guard below is biased the same way: when the page is behaving oddly, say nothing and re-sync.
Guard 1: an empty page is not an empty market
if (current.length === 0) {
log(`Skipping diff for ${site.name}, the check returned 0 listings`);
siteState.markFailure(site.url, 'error', { message: 'No listings on the page' });
return;
}
Zero results is almost never real. A search that matched forty rooms last cycle did not match none thirty seconds later. It means a block page, a redirect to a login screen, a layout change, or a load that finished before the grid rendered.
The important part is that it returns before touching the stored baseline. If zero had been accepted as the new truth, the next successful check would see forty listings appear out of nowhere, and every one of them would look new.
That is the pattern in all of these: a check you do not believe must not be allowed to become the thing you compare against.
Guard 2: the first check after the app starts never alerts
This one is a product rule rather than a data-quality rule, and it lives in its own module so it can be read and tested without Electron in the way:
export function shouldRebase(hasStoredBaseline: boolean, firstCheckOfRun: boolean): boolean {
return hasStoredBaseline && firstCheckOfRun;
}
The stored baseline records what a page held when the app was last open. If you quit on Friday and open the app on Monday, that baseline is three days old, and diffing against it would produce an inbox full of rooms that were let over the weekend.
An alert about a room that is already gone is worse than no alert. It costs the user a click, a page load, and a small amount of faith.
So the first check of each search after the app starts sets a fresh baseline and says so:
Baseline set for Kamernet: 38 listing(s) on the page now, last checked 2 days ago.
Alerts start from the next check
Note what it keys on: the lifetime of the process, not elapsed time. There is no "if the baseline is older than N hours" threshold, because any N would be wrong. Two minutes of downtime is enough for a room to go, and eight hours of the app sitting in the tray is not a reason to re-sync anything.
The tracker is a Set<string> of URLs checked so far this run. Hiding the window, sleeping the laptop, or stopping and restarting the monitor from the tray all keep the comparison going, because none of those end the process. Quitting ends it, and quitting is the only thing that should.
Guard 3: the half-loaded page
if (previous.length >= 4 && current.length < previous.length * 0.5) {
const skips = (_partialSkips[site.url] ?? 0) + 1;
_partialSkips[site.url] = skips;
if (skips < PARTIAL_SKIP_LIMIT) {
log(`Skipping ${site.name}: only ${current.length} listing(s) vs ${previous.length} in baseline`);
return;
}
...
}
Listing grids load lazily. A slow network, and the scrape captures nine of the forty cards that will exist a second later.
Nine versus forty is not, by itself, an alertable event: nothing new appeared, things vanished. The reason it needs a guard at all is the cycle after it. If nine is saved as the baseline, the next full load shows thirty one listings that were not in the baseline, which is a flood of false alerts, or, once the ratio guard below catches it, a search that alerts on nothing for a while.
The counter is the part I would not skip. A site can genuinely halve its result count: you tightened the search, or the market is thin this week. Refusing to look at a search forever because it got smaller is a bug that presents as silence, which is the hardest kind to notice.
// Three checks in a row have agreed, so this is the page now.
saveSnapshot(site.url, current);
log(`${site.name} has settled at ${current.length} listing(s), down from ${previous.length}`);
Three consecutive checks agreeing is the difference between an anomaly and a fact. Any suppression rule needs an escape hatch like this, or you have written a mute button with no unmute.
Guard 4: if almost everything is new, nothing is
const newRatio = newListings.length / current.length;
if (newRatio >= 0.8 && current.length > 3) {
log(`Skipping alert for ${site.name}: ${newListings.length}/${current.length} appear new`);
saveSnapshot(site.url, current);
return;
}
The last one, and the one that catches everything the first three did not think of.
Real rental sites do not replace 80% of a results page in thirty seconds. When the diff says they did, something changed about how the page was rendered rather than what it contains: a session dropped and the site served a logged out view, an A/B test moved the grid, a sort order flipped, an identifier scheme changed.
Two details worth copying. The current.length > 3 clause exempts small searches, where three new rooms out of four really can be three new rooms. And the snapshot is saved before returning, because this state is self correcting only if the app accepts the new shape of the page as the thing to compare against next time. Skipping the save leaves it re-detecting the same 80% difference every thirty seconds, forever.
What the user sees
None of this is hidden. Each suppression writes a line into the activity log and a short message onto the search row: "Fresh start after being closed", "Page looked half-loaded", "Page changed shape, re-based". Silence with a reason attached is a feature. Silence on its own is indistinguishable from a broken app, which is precisely the failure this whole design is trying to avoid.
See it for yourself
The rule about what counts as new, and what happens when you quit and reopen, is written up for users on the help page, in the same terms as this post. The app is at notifio.app/download, and the sites it watches, each with its own set of page quirks, are at notifio.app/alerts.
If you are building something similar, the summary is: write the diff, then budget four times as long for deciding when to ignore it.
Top comments (0)