Skip to content

Add webcam feed extractor tool - #33

Open
yayadrian wants to merge 1 commit into
mainfrom
codex/create-tool-to-extract-m3u8-feed-url
Open

Add webcam feed extractor tool#33
yayadrian wants to merge 1 commit into
mainfrom
codex/create-tool-to-extract-m3u8-feed-url

Conversation

@yayadrian

Copy link
Copy Markdown
Owner

Motivation

  • Provide a small web utility that accepts a webcam page URL and extracts any HLS .m3u8 stream links found in the page HTML or embedded iframes.
  • Handle CORS and relative URLs by resolving links against the page base and using a read-only fetch proxy for HTML retrieval.

Description

  • Add a new static tool at webcam-feed-extractor/index.html with a polished UI that accepts a page URL and shows discovered .m3u8 feeds and status messages.
  • Implement client-side extraction logic including extractM3u8Links, extractIframeSources, and normalizeUrls, plus a regex search for absolute .m3u8 links and attribute scanning for src, href, data-src, and data-href.
  • Use a read-only proxy (https://r.jina.ai/http/...) to fetch remote HTML and iterate iframes to find nested feeds, and render unique primary/alternate links.
  • Update the repository root index.html to link the new webcam-feed-extractor tool.

Testing

  • Ran python3 update_index.py to refresh the root tools index, which completed successfully.
  • Served the site with python3 -m http.server 8000 and ran a Playwright script to open http://127.0.0.1:8000/webcam-feed-extractor/ and capture a screenshot, which produced an artifact indicating the page rendered.
  • No automated unit tests were added for the static HTML/JS; manual/visual verification was performed via the headless browser run.

Codex Task

Copilot AI review requested due to automatic review settings February 3, 2026 16:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c8bcc4581

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new webcam feed extractor tool that allows users to input a webcam page URL and automatically discovers HLS .m3u8 stream links from the page HTML and embedded iframes. The tool uses a read-only proxy service to bypass CORS restrictions and provides a polished UI for displaying discovered feeds.

Changes:

  • New single-page HTML tool with inline CSS and JavaScript for extracting .m3u8 stream URLs
  • Client-side extraction logic using regex patterns, DOM parsing, and recursive iframe scanning
  • Updated repository index to include the new tool in alphabetical order

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
webcam-feed-extractor/index.html Complete implementation of the webcam feed extractor tool with extraction logic, UI, and styling
index.html Added link to the new webcam-feed-extractor tool in the alphabetically-sorted tools list

</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.
Comment on lines +208 to +337
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?:\/\//, "")}`;

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) => {
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);
}
}

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);
}
});

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.
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.
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.
Comment on lines +314 to +327
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);
}
}

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.
<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.
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>
<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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants