Building a free beach-chair detector using Simplescraper, LLMs and public webcams
Reliably finding an available sun lounger at your favorite beach is an unsolved problem - until now. Using live public webcams, a little data-extraction and the power of AI, it’s possible to ensure that you’re first to know when a spot in the sun is available.
In this guide we'll create a tool that tells you exactly how many lounge chairs are available in realtime (handling edge cases like beach towels left on loungers to reserve spot) and updates you automatically via email.
Here's our toolkit:
- Simplescraper (for data and screenshot/image extraction)
- OpenAI (for AI analysis)
- Val Town (for hosting and running our code)
And here’s how we’ll make it work:
Simplescraper fetches the live footage from the webcam
An LLM (OpenAI’s gpt-4o-mini) analyzes the image and counts the number of available chairs
Val Town wraps all this together and sends this info to us automatically via email
We bathe
Let’s get started (scroll to the end for the ready-to-run code).
Table of contents
Step 1: Capturing and Extracting the images from the webcam
We’ll use this live webcam from Chaweng beach, one of the most popular beaches on the tropical Thai island of Koh Samui. Not your local beach? That's ok - there’s a whole bunch of webcams streaming from all types of places, so choose whichever you prefer.
If you’d rather use another live stream, you only need to copy the video ID that comes after the 'v' in the URL (so in the URL youtube.com/watch?v=qbu2qsqJB_A, the ID is qbu2qsqJB_A). You may need to click through to Youtube to view this URL.
Now that we have the ID of the video we wish to capture, Simplescraper makes the rest easy:
- Visit this scrape recipe template to copy it to your dashboard: https://simplescraper.io/?sharedRecipe=DBhcTpGGY26XtZizqKf0
- Click the ‘use in new recipe’ button
- Replace the video ID in the URL field with the ID of the video you wish to capture
- Save the recipe
- Click the API tab to find the API URL and copy it to your clipboard
That’s the Simplescraper part complete, now it’s time to call the API.
Visit Val Town and click on the pink ‘Create new cron’ button:
Now paste the following Javascript code into the code editor, replacing SIMPLESCRAPER_API_ENDPOINT
with the URL from the previous step:
import { email } from "https://esm.town/v/std/email";
const OPENAI_API_KEY = Deno.env.get("OPENAI_KEY"); // replace with your OpenAI API key
const OPENAI_API_ENDPOINT = "https://api.openai.com/v1/chat/completions";
const SIMPLESCRAPER_API_KEY = Deno.env.get("SS_KEY"); // replace with your Simplescraper API key
const SIMPLESCRAPER_API_ENDPOINT = "https://api.simplescraper.io/v1/recipes/DBhcTpGGY26XtZizqKf0/run"; // replace with your API URL
// run scrape recipe and return data from simplescraper
async function runRecipe(apiKey, url) {
const requestBody = {};
try {
console.log("calling simplescraper");
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const result = await response.json();
return result;
} catch (error) {
console.error("Error fetching recipe:", error);
return null;
}
}
// Main function that controls process ---------------------------------
export async function main() {
console.log("starting main process...");
// 1. get the screenshot url
let screenshotUrl;
const result = await runRecipe(SIMPLESCRAPER_API_KEY, SIMPLESCRAPER_API_ENDPOINT);
if (result && result.status === "completed") {
screenshotUrl = result.screenshots?.[0]?.screenshot || null;
console.log("Screenshot URL:", screenshotUrl);
} else {
console.log("Failed to retrieve recipe data or screenshot URL");
}
}
This code scrapes the webcam video on Youtube, takes a screen capture and makes the image available as the variable screenshotUrl
.
Next we'll teach the AI to count beach chairs.
Step 2: Analyzing the screen capture using AI
In this step we’ll send the screenshot to OpenAI’s AI models with a prompt requesting the occupancy number, percentage as well as a general description of the scene (zero occupied seats because it’s raining isn’t ideal, so we’ll want to know that info too).
With those goals in mind, here's our prompt:
"Analyze this image to determine the count and percentage of unoccupied/free beach chairs, and provide a description explaining your analysis, including reasoning behind the conclusion, and the condition of the scene. Double-check that seats are really unoccupied. Write in tone of a weather update or bulletin. Be light, brief and professional”
To ensure that the reponse structure is consistent, we’ll define a JSON schema that instructs the response to always follow this format: {count, percentage, description}
(check out this OpenAI guide on structured outputs for more info on this helpful feature).
Let’s put all this together in a new function which we’ll call analyzeImageContent:
// analyze image using OpenAI
async function analyzeImageContent(imageUrl) {
const prompt =
"Analyze this image to determine the count and percentage of unoccupied/free beach chairs, and provide a description explaining your analysis, including reasoning behind the conclusion, and the condition of the scene. Double-check that seats are really unoccupied. Write in tone of a weather update or bulletin. Be light, brief and professional”";
const content = [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: imageUrl } },
];
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${OPENAI_API_KEY}`,
};
const requestBody = {
model: "gpt-4o-mini",
temperature: 0.1,
response_format: {
type: "json_schema",
json_schema: {
name: "percent",
strict: false,
schema: {
type: "object",
properties: {
count: { type: "number" },
percentage: { type: "number" },
description: { type: "string" },
},
required: ["count", "percentage", "description"],
additionalProperties: false,
},
},
},
messages: [
{
role: "user",
content: content,
},
],
};
try {
console.log("analyzeImageContent - before fetch");
const response = await fetch(OPENAI_API_ENDPOINT, {
method: "POST",
headers: headers,
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (data.error) {
console.error("OpenAI API error:", data.error);
return null;
}
if (!data.choices || data.choices.length === 0) {
console.error("No valid data returned.");
return null; // return null if OpenAI did not return choices
}
const analysisResult = JSON.parse(data.choices[0].message.content);
const returnObj = {
count: analysisResult.count || 0,
percentage: analysisResult.percentage || 0,
description: analysisResult.description || "No description available.",
};
// console.log("Usage:", data.usage);
return returnObj;
} catch (error) {
console.error("Request failed:", error);
return null;
}
}
// Main function that controls process ---------------------------------
export async function main() {
console.log("starting main process...");
// 1. get the screenshot url
let screenshotUrl;
const result = await runRecipe(SIMPLESCRAPER_API_KEY, SIMPLESCRAPER_API_ENDPOINT);
if (result && result.status === "completed") {
screenshotUrl = result.screenshots?.[0]?.screenshot || null;
console.log("Screenshot URL:", screenshotUrl);
} else {
console.log("Failed to retrieve recipe data or screenshot URL");
}
// 2. analyse the screenshot
const analysisData = await analyzeImageContent(screenshotUrl);
if (!analysisData) {
console.log("Analysis failed.");
return;
}
console.log("Analysis Result:", analysisData);
}
Click run and the AI will answer the question we’ve all been waiting for, how many beach chairs are free:
“Good morning from Chaweng Beach! Today, the beach scene appears quite tranquil. Upon close inspection, it seems that all beach chairs are currently unoccupied, leading to a total count of 0 occupied chairs out of a visible total of 10. This results in a delightful 100% availability rate. The beach is serene, with a few scattered items and a calm sea, making it an ideal spot for relaxation later in the day. Enjoy the beautiful weather!”
And this is how the JSON response looks:
{
count: 0,
percentage: 0,
description: "Good morning from Chaweng Beach..."
}
In two easy steps we’ve built a system that monitors a live cam, assesses the scene and provides accurate updates. But we’re busy people, and the weather’s apparently beautiful, so let’s see how we can step away from the screen by putting this system on auto-pilot.
Step 3: Sending Automatic email updates
We’ll update the script to automatically run on a schedule and send an email update with the latest goings on from the video feed. Val Town makes both of these steps simple.
3.1 Sending the email
To do this let’s create a new function called sendUpdateEmail:
// import email module to allow sending emails
import { email } from "https://esm.town/v/std/email";
async function sendUpdateEmail(returnObj, imageUrl) {
const { count, percentage, description } = returnObj;
// construct subject and body
const subject = `Beach Chair Update: ${percentage}% Occupied (${count} chairs filled)`;
const text =
`Here's the latest on beach chair occupancy:\n\n- Occupied Chairs: ${count}\n- Occupancy Percentage: ${percentage}%\n\nDetails:\n${description}\n\nView Image: ${imageUrl}`;
// send email
try {
await email({
subject: subject,
text: text,
});
console.log("Email with image link sent successfully");
} catch (error) {
console.error("Failed to send email with image link:", error);
}
}
Here’s what’s happening: we import the email module (see here for more details), define the sendUpdateEmail
function to accept the AI analysis data (count
, percentage
, description
) and image URL, then compose an email including these details. Finally, we send the email using the email
function .
The email will be sent to your Val Town account email address, however paid accounts can change this.
3.2 Making this script run automatically on schedule
As we selected a Cron type Val earlier, all we need to do is click the gear icon at the top right-hand side of the code editor and enter a schedule. It can be as simple as run every 2 hours, run every day, to more advanced scheduling that includes cron expressions.
You can find more info here but let’s stick with every 2 hours for now.
So we’ve screengrabbed the video feed, passed it to OpenAI for analysis, formatted it as an email and set a schedule. All that’s left to do now is click the big blue ‘Run now’ button and after a few seconds you should see an email like this in your inbox:
Your automatic beach-chair-availability-alert-system is up and running! Here's the code in full, ready to run:
With millions of public webcams pointing at all kinds of interesting stuff, and AI’s ability to understand images, there's endless possibilities to build all kinds of neat tools. By just changing the video URL and the prompt you can extend this system to answer questions like:
- Is there a parking space free?
- Are the waves good?
- Is the queue long?
- Is the store open?
- Is my favorite venue busy?
- Etc.
All in real-time. Get building!