Track real-estate listings for new matches
Monitor property feeds for Northwind office hunts — alert when a match appears.
Published Nov 28, 2025
Northwind is outgrowing its office, and the good listings go fast. The current routine is someone refreshing a property site a few times a day and hoping to catch a new one before it is snapped up — easy to forget, easy to miss.
This script watches a saved property search for you. Each run fetches the search results page, picks out the listings, and compares them against the ones it has already seen. Anything new gets logged to a sheet and emailed straight to the studio, so a fresh match lands in the inbox within minutes instead of depending on someone remembering to look.
What you’ll need
- A Google Sheet to hold seen listings. The script writes URLs to column A and uses them to decide what is new, so leave that column for the script.
- A saved-search URL on a property site whose results page renders listings in the page HTML (not loaded later by JavaScript — see “Watch out for”).
- The link-matching pattern below tuned to that site’s markup. The example
matches
<a class="listing" href="...">Title</a>; adjust it to fit. - The sheet ID and the alert email address set in the config block below.
The script
// The spreadsheet that records every listing already seen.
const LISTINGS_SHEET_ID = '1abcListingsId';
// Where new-match alerts are sent.
const ALERT_EMAIL = '[email protected]';
// Pulls listing links out of the results HTML. Tune this to match
// the markup of the site you are watching — see "Watch out for".
const LISTING_PATTERN =
/<a class="listing"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
/**
* Fetches a saved property search, logs any listings not seen before,
* and emails the studio when fresh matches turn up.
*
* @param {string} searchUrl The saved-search results page to watch.
*/
function trackListings(searchUrl) {
if (!searchUrl) {
Logger.log('No searchUrl passed — nothing to track.');
return;
}
const sheet = SpreadsheetApp.openById(LISTINGS_SHEET_ID).getSheets()[0];
// 1. Build a set of URLs already logged, so we can spot what is new.
const known = new Set(
sheet.getRange('A2:A').getValues().flat().filter(Boolean)
);
// 2. Fetch the search results page.
const response = UrlFetchApp.fetch(searchUrl, { muteHttpExceptions: true });
if (response.getResponseCode() !== 200) {
Logger.log('Search request failed: ' + response.getResponseCode());
return;
}
// 3. Extract every listing link and title from the HTML.
const html = response.getContentText();
const matches = [...html.matchAll(LISTING_PATTERN)];
// 4. Keep only listings whose URL we have not logged before.
const fresh = matches.filter(([, url]) => !known.has(url));
if (!fresh.length) {
Logger.log('No new listings this run.');
return;
}
// 5. Log each new listing with the time it was first seen.
for (const [, url, title] of fresh) {
sheet.appendRow([url, title, new Date()]);
}
// 6. Email the studio a digest of everything new.
GmailApp.sendEmail(
ALERT_EMAIL,
fresh.length + ' new listings',
fresh.map((m) => m[2] + ': ' + m[1]).join('\n')
);
Logger.log('Logged and alerted ' + fresh.length + ' new listings.');
}
How it works
trackListingstakes the saved-search URL as an argument. If none is passed it logs a message and stops, so a misconfigured trigger fails cleanly.- It reads column A of the sheet into a
Setof URLs already seen. ASetmakes the “have we seen this?” check instant, even with hundreds of rows. - It fetches the search results page.
muteHttpExceptionslets the script check the response code and bail out on a failure rather than throwing. matchAllrunsLISTING_PATTERNover the HTML and pulls out every listing’s URL and title. The pattern’s two capture groups are the href and the visible text.- It filters those matches down to listings whose URL is not in the
knownset — the genuinely new ones. - Each new listing is appended to the sheet with a timestamp, and a single digest email goes to the studio. If nothing is new, the run ends quietly with no email.
Example run
The sheet already knows two listings. The search page now returns three, one of them new. After the run, the sheet has gained a row:
| url | title | seen |
|---|---|---|
| /property/12 | 2-desk studio, Shoreditch | 2026-05-20 09:00 |
| /property/27 | Open-plan loft, Hackney | 2026-05-22 14:30 |
| /property/41 | Warehouse unit, Bethnal Green | 2026-05-25 08:00 |
And the studio inbox receives one email:
Subject: 1 new listings
2026-05-25T08:00:00.000Z: Warehouse unit, Bethnal Green
On the next run, listing 41 is already in column A, so it is treated as seen and no duplicate email goes out.
Trigger it
Run this on a schedule so new matches surface without anyone refreshing a page. Because the function needs the search URL, drive it from a small wrapper:
/**
* Trigger entry point — passes the saved search to trackListings.
* Point time triggers at this function.
*/
function checkOfficeSearch() {
trackListings('https://example-property-site.co.uk/search?area=hackney');
}
- In the Apps Script editor, open Triggers (the clock icon).
- Add a trigger: choose
checkOfficeSearch, event source Time-driven, an Hour timer every few hours. - Save and approve the authorisation prompt the first time.
Watch out for
- This only works if listings appear in the page HTML. Many property sites load
results with JavaScript after the page arrives, in which case
UrlFetchAppsees an empty shell. Prefer the site’s official API or RSS feed where one exists. - The regex is fragile by nature. A site redesign will change the markup and
silently break
LISTING_PATTERN— if alerts go quiet, check the HTML before assuming there are no new listings. - Match listings by a stable URL, not by title. Two flats can share a title, and a URL with a tracking parameter will look “new” every run.
- Check the site’s terms of service. Some prohibit automated fetching; an official feed keeps you on the right side of that.
GmailAppsends from your account and counts against the daily mail quota. One digest per run is well within budget; do not send one email per listing.
Related
Sync calendar bookings with Calendly
Bridge Google Calendar and Calendly — Northwind bookings on either side appear on both.
Updated Jan 7, 2026
Connect to an air-quality and weather feed
Build a Northwind environmental dashboard — current London AQI plus 5-day forecast.
Updated Dec 30, 2025
Build a podcast and media stats tracker
Pull Northwind's podcast download numbers across platforms into a single sheet.
Updated Dec 10, 2025
Translate columns with a translation API
Localise Northwind text in bulk without manual work — via Google Translate or DeepL.
Updated Nov 24, 2025
Build a job-listings aggregator
Collect Northwind-relevant postings via public job-board APIs into a sheet for the team.
Updated Nov 20, 2025