Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ <h1>TOOLS</h1>
<li><a href="nutri-calculator/">nutri-calculator</a></li>
<li><a href="parkrun-weather-advisor/">parkrun-weather-advisor</a></li>
<li><a href="rice-cooking-calculator/">rice-cooking-calculator</a></li>
<li><a href="webcam-feed-extractor/">webcam-feed-extractor</a></li>
<li><a href="webcam-tiles/">webcam-tiles</a></li>
<li><a href="working-hours-calculator/">working-hours-calculator</a></li>
<li><a href="yt-playlist-to-music/">yt-playlist-to-music</a></li>
Expand Down
340 changes: 340 additions & 0 deletions webcam-feed-extractor/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Webcam Feed Extractor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--card: #ffffff;
--primary: #2155f5;
--text: #101828;
--muted: #667085;
--border: #e4e7ec;
--success: #12b76a;
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 16px;
display: flex;
justify-content: center;
}

.container {
width: min(900px, 100%);
}

header {
margin-bottom: 24px;
}

h1 {
font-size: clamp(2rem, 3vw, 2.6rem);
font-weight: 700;
margin-bottom: 12px;
}

p {
color: var(--muted);
line-height: 1.6;
}

.card {
background: var(--card);
border-radius: 20px;
border: 1px solid var(--border);
padding: 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
}

form {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
margin-top: 16px;
}

label {
font-weight: 600;
margin-bottom: 8px;
display: block;
}

input[type="url"] {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
font-size: 1rem;

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input font size uses 1rem but the repository convention in RULES.md line 13 and consistently followed in other tools (e.g., rice-cooking-calculator line 82, hybrid-petrol-calculator line 65) is to explicitly use 16px for inputs and textareas.

Copilot generated this review using guidance from repository custom instructions.
width: 100%;
}

button {
padding: 12px 18px;
border-radius: 12px;
border: none;
background: var(--primary);
color: white;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.2s ease;
}

button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 18px rgba(33, 85, 245, 0.2);
}

button:disabled {
cursor: not-allowed;
background: #98a2b3;
box-shadow: none;
transform: none;
}

.helper {
margin-top: 16px;
background: #f0f4ff;
padding: 14px 16px;
border-radius: 12px;
font-size: 0.95rem;
}

.results {
margin-top: 24px;
display: grid;
gap: 12px;
}

.result-item {
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #fbfcff;
}

.result-item a {
color: var(--primary);
text-decoration: none;
word-break: break-all;
}

.tag {
font-size: 0.8rem;
background: #eef4ff;
color: #1d3ed1;
padding: 4px 8px;
border-radius: 999px;
font-weight: 600;
}

.status {
margin-top: 16px;
color: var(--muted);
}

.status.success {
color: var(--success);
font-weight: 600;
}

.status.error {
color: #b42318;
font-weight: 600;
}

@media (max-width: 720px) {
form {
grid-template-columns: 1fr;
}

button {
width: 100%;
}

.result-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Webcam Feed Extractor</h1>
<p>Paste a webcam page URL and this tool will scan the HTML (including embedded iframes) to find any <strong>.m3u8</strong> HLS streams.</p>
</header>

<section class="card">
<label for="pageUrl">Webcam page URL</label>
<form id="lookupForm">
<input
id="pageUrl"
name="pageUrl"
type="url"
placeholder="https://www.camsecure.co.uk/swanage_lifeboat_webcam.html"
required
>
<button type="submit" id="lookupButton">Find feeds</button>
</form>
<div class="helper">
Tip: The extractor uses a read-only fetch proxy to bypass CORS. It only searches the HTML for direct links to <code>.m3u8</code> files.
</div>
<div id="status" class="status"></div>

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status message div should include aria-live="polite" to announce dynamic content changes to screen reader users. This pattern is consistently used in other tools like rice-cooking-calculator (lines 394-395) and hybrid-petrol-calculator (line 233, 253).

Suggested change
<div id="status" class="status"></div>
<div id="status" class="status" aria-live="polite"></div>

Copilot uses AI. Check for mistakes.
<div id="results" class="results"></div>

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The results container should include aria-live="polite" to announce when new feed links are discovered and rendered to screen reader users. This follows the accessibility pattern consistently used in other tools like rice-cooking-calculator and hybrid-petrol-calculator.

Suggested change
<div id="results" class="results"></div>
<div id="results" class="results" aria-live="polite"></div>

Copilot uses AI. Check for mistakes.
</section>
</div>

<script>

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script tag should use type="module" attribute to follow the repository convention established in RULES.md lines 14-18 and consistently used in other tools like rice-cooking-calculator, hybrid-petrol-calculator, and christmas-timer.

Copilot generated this review using guidance from repository custom instructions.
const form = document.getElementById("lookupForm");
const pageUrlInput = document.getElementById("pageUrl");
const statusEl = document.getElementById("status");
const resultsEl = document.getElementById("results");
const buttonEl = document.getElementById("lookupButton");

const proxyUrl = (url) => `https://r.jina.ai/http://${url.replace(/^https?:\/\//, "")}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve target scheme when building proxy URL

The proxy URL builder strips the input scheme and always prefixes http://, so any webcam page (or iframe) that is HTTPS-only and does not redirect from HTTP will fail to load through the proxy. This makes the extractor silently miss feeds on sites that serve only HTTPS content. Consider preserving the original scheme (or explicitly supporting https:// in the proxy path) so HTTPS-only pages can be fetched.

Useful? React with 👍 / 👎.


const normalizeUrls = (urls, baseUrl) => {
const resolved = urls.map((url) => {
try {
return new URL(url, baseUrl).toString();
} catch (error) {
return null;
}
});
return [...new Set(resolved.filter(Boolean))];
};

const extractM3u8Links = (html, baseUrl) => {
const links = [];
const regex = /https?:\/\/[^\s"'<>]+?\.m3u8(?:\?[^\s"'<>]+)?/gi;
const matches = html.match(regex) || [];
links.push(...matches);

const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const attrs = ["src", "href", "data-src", "data-href"];
doc.querySelectorAll("*" ).forEach((node) => {

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an extra space before the closing parenthesis in the querySelectorAll call. This should be doc.querySelectorAll("*") without the trailing space.

Suggested change
doc.querySelectorAll("*" ).forEach((node) => {
doc.querySelectorAll("*").forEach((node) => {

Copilot uses AI. Check for mistakes.
attrs.forEach((attr) => {
const value = node.getAttribute(attr);
if (value && value.includes(".m3u8")) {
links.push(value);
}
});
});

return normalizeUrls(links, baseUrl);
};

const extractIframeSources = (html, baseUrl) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const sources = Array.from(doc.querySelectorAll("iframe[src]"))
.map((iframe) => iframe.getAttribute("src"))
.filter(Boolean);
return normalizeUrls(sources, baseUrl);
};

const renderResults = (links) => {
resultsEl.innerHTML = "";
if (links.length === 0) {
statusEl.textContent = "No .m3u8 feeds found in the page HTML.";
statusEl.className = "status error";
return;
}

statusEl.textContent = `Found ${links.length} feed${links.length > 1 ? "s" : ""}.`;
statusEl.className = "status success";

links.forEach((link, index) => {
const item = document.createElement("div");
item.className = "result-item";
const anchor = document.createElement("a");
anchor.href = link;
anchor.textContent = link;
anchor.target = "_blank";
anchor.rel = "noopener";

const tag = document.createElement("span");
tag.className = "tag";
tag.textContent = index === 0 ? "Primary" : "Alternate";

item.append(anchor, tag);
resultsEl.appendChild(item);
});
};

const setLoading = (isLoading) => {
buttonEl.disabled = isLoading;
buttonEl.textContent = isLoading ? "Scanning..." : "Find feeds";
};

form.addEventListener("submit", async (event) => {
event.preventDefault();
const pageUrl = pageUrlInput.value.trim();

if (!pageUrl) {
return;
}

statusEl.textContent = "";
statusEl.className = "status";
resultsEl.innerHTML = "";
setLoading(true);

try {
const response = await fetch(proxyUrl(pageUrl));
if (!response.ok) {
throw new Error("Unable to fetch the page HTML.");
}

const html = await response.text();
const directLinks = extractM3u8Links(html, pageUrl);
const iframeSources = extractIframeSources(html, pageUrl);

let iframeLinks = [];
for (const iframeUrl of iframeSources) {
try {
const iframeResponse = await fetch(proxyUrl(iframeUrl));
if (!iframeResponse.ok) {
continue;
}
const iframeHtml = await iframeResponse.text();
iframeLinks = iframeLinks.concat(extractM3u8Links(iframeHtml, iframeUrl));
} catch (error) {
console.warn("Iframe fetch failed", iframeUrl, error);
}
}

Comment on lines +314 to +327

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iframe sources are fetched sequentially in a for-of loop. For better performance, consider using Promise.all or Promise.allSettled to fetch all iframes concurrently. This would be especially beneficial when there are multiple iframes to process.

Suggested change
let iframeLinks = [];
for (const iframeUrl of iframeSources) {
try {
const iframeResponse = await fetch(proxyUrl(iframeUrl));
if (!iframeResponse.ok) {
continue;
}
const iframeHtml = await iframeResponse.text();
iframeLinks = iframeLinks.concat(extractM3u8Links(iframeHtml, iframeUrl));
} catch (error) {
console.warn("Iframe fetch failed", iframeUrl, error);
}
}
const iframePromises = iframeSources.map(async (iframeUrl) => {
try {
const iframeResponse = await fetch(proxyUrl(iframeUrl));
if (!iframeResponse.ok) {
return [];
}
const iframeHtml = await iframeResponse.text();
return extractM3u8Links(iframeHtml, iframeUrl);
} catch (error) {
console.warn("Iframe fetch failed", iframeUrl, error);
return [];
}
});
const iframeResults = await Promise.all(iframePromises);
const iframeLinks = iframeResults.reduce((all, links) => all.concat(links), []);

Copilot uses AI. Check for mistakes.
const allLinks = [...new Set([...directLinks, ...iframeLinks])];
renderResults(allLinks);
} catch (error) {
console.error(error);
statusEl.textContent = "Something went wrong fetching the page. Please double-check the URL.";
statusEl.className = "status error";
} finally {
setLoading(false);
}
});
Comment on lines +208 to +337

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JavaScript code inside the script tag uses 4-space indentation, but the repository convention in RULES.md line 18 states "The top-level inside that tag is not indented." All top-level JavaScript statements (lines 208-337) should have no indentation. This convention is consistently followed in other tools like rice-cooking-calculator (line 436), hybrid-petrol-calculator (line 260), and christmas-timer (line 290).

Copilot generated this review using guidance from repository custom instructions.
</script>
</body>
</html>