Add webcam feed extractor tool - #33
Conversation
There was a problem hiding this comment.
💡 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?:\/\//, "")}`; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
.m3u8stream 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> |
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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).
| padding: 12px 14px; | ||
| border-radius: 12px; | ||
| border: 1px solid var(--border); | ||
| font-size: 1rem; |
There was a problem hiding this comment.
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.
| const parser = new DOMParser(); | ||
| const doc = parser.parseFromString(html, "text/html"); | ||
| const attrs = ["src", "href", "data-src", "data-href"]; | ||
| doc.querySelectorAll("*" ).forEach((node) => { |
There was a problem hiding this comment.
There's an extra space before the closing parenthesis in the querySelectorAll call. This should be doc.querySelectorAll("*") without the trailing space.
| doc.querySelectorAll("*" ).forEach((node) => { | |
| doc.querySelectorAll("*").forEach((node) => { |
| 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); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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), []); |
| <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> |
There was a problem hiding this comment.
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).
| <div id="status" class="status"></div> | |
| <div id="status" class="status" aria-live="polite"></div> |
| 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> |
There was a problem hiding this comment.
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.
| <div id="results" class="results"></div> | |
| <div id="results" class="results" aria-live="polite"></div> |
Motivation
.m3u8stream links found in the page HTML or embedded iframes.Description
webcam-feed-extractor/index.htmlwith a polished UI that accepts a page URL and shows discovered.m3u8feeds and status messages.extractM3u8Links,extractIframeSources, andnormalizeUrls, plus a regex search for absolute.m3u8links and attribute scanning forsrc,href,data-src, anddata-href.https://r.jina.ai/http/...) to fetch remote HTML and iterate iframes to find nested feeds, and render unique primary/alternate links.index.htmlto link the newwebcam-feed-extractortool.Testing
python3 update_index.pyto refresh the root tools index, which completed successfully.python3 -m http.server 8000and ran a Playwright script to openhttp://127.0.0.1:8000/webcam-feed-extractor/and capture a screenshot, which produced an artifact indicating the page rendered.Codex Task