Web pages, Vimeo, Loom and Wistia#
Two keyless providers cover videos that are not on YouTube:
providers/web-pagefinds every video embedded in an article, course or landing page.providers/native-transcriptsreads Vimeo, Loom and Wistia videos and the captions those platforms already publish, so no audio is downloaded and no speech-to-text runs when a track exists.
Both run on Node and Cloudflare Workers. Neither needs a key or an env var.
The page: form#
npm run cli -- "page:https://www.openculture.com/2012/07/ts_eliot_reads_his_modernist_masterpieces.html"A page: prefix asks for the page's videos. Without the prefix, a plain URL goes to the platform's own source when there is one (YouTube, Vimeo, Loom, Wistia, ...) and otherwise to yt-dlp, which already handles a page with a single embed. The web-page provider never claims plain URLs, so it cannot take work away from yt-dlp.
page:<url> is a list ref (kind: 'page'). expandInput, GET /items and the MCP list_items tool turn it into video refs, in page order, each video once.
Surfaces can also call the discovery directly on any URL:
import { discoverVideos } from '@vid2context/provider-web-page';
const videos = await discoverVideos('https://example.com/article', ctx);
// [{ input: 'https://www.youtube.com/watch?v=...', platform: 'youtube', source: 'iframe', title?: ... }, ...]What discovery finds#
source |
Where it looks |
|---|---|
iframe |
<iframe>, <embed> and <object> players from YouTube (including youtube-nocookie.com), Vimeo (player.vimeo.com/video/), Loom (loom.com/embed/), Wistia (fast.wistia.net/embed/, <account>.wistia.com/medias/, the wistia_async_<id> class and the <wistia-player media-id> element), Dailymotion, Twitch, Rumble and TED. Lazy data-src attributes count too. |
video-tag |
<video src> and <source src> inside <video>, resolved against the page URL (and <base href>). <source> inside <picture> or <audio> is ignored. |
og |
Open Graph og:video, og:video:url, og:video:secure_url. |
json-ld |
schema.org VideoObject anywhere in JSON-LD: top level, arrays, @graph, nested in an article. Reads embedUrl, contentUrl, url, @id, name, description, uploadDate, duration, thumbnailUrl, inLanguage, author and transcript. |
oembed |
<link rel="alternate" type="application/json+oembed"> whose url= names a known video page. A site's oEmbed link for the article itself is not a video and is skipped. |
link |
<a href> to a known video page or a media file (.mp4, .webm, .m3u8, ...). Channel and playlist links are not videos and are skipped. |
Each result's input is what another source resolves: embeds become the platform's watch URL (a YouTube embed becomes https://www.youtube.com/watch?v=..., a Twitch player URL becomes https://www.twitch.tv/videos/...). When the same video appears several times (an iframe plus the JSON-LD and Open Graph that describe it), it is listed once, at its first position, with the details merged.
Which refs page: expands to#
The default web-page source resolves discovered inputs with the matchers it can reach on its own: YouTube, Vimeo, Loom and Wistia. Videos on other platforms, and media files, are skipped and logged at info level. A surface that holds a Registry can resolve through every installed source (yt-dlp included) instead:
import { createWebPageSource, registryResolve } from '@vid2context/provider-web-page';
const source = createWebPageSource({ resolve: registryResolve(registry) });Transcripts published in the page#
Some pages publish the transcript in their JSON-LD (TED talk pages do). When a video has such a transcript and no platform-specific source owns it, page: yields a page-video:<n>:<page url> ref (the n-th VideoObject on the page). Its metadata comes from the JSON-LD, and the web-page-transcript captions provider returns the text as a native-transcript, one segment per paragraph. The text has no timings and none are invented: every segment runs from 0 to the video's duration when the duration is known (0 to 0 otherwise), and the producer is web-page-transcript/untimed so every surface can tell.
Fetching limits#
Pages are fetched with these limits, so a page: input cannot be used to reach private systems:
- http and https only; URLs with a user name or password are refused;
- hosts that are local or private are refused before any request, and again on every redirect:
localhost,*.localhost,*.local,*.internal,*.home.arpa, single-label names, and loopback, private, link-local, carrier-grade NAT, multicast and reserved IP literals (IPv4, IPv6, IPv4-mapped and NAT64 forms); - at most 5 redirects, 20 seconds per request;
- at most 2 MB of HTML is read; a longer page is parsed as far as the cap and a warning is logged;
- a non-HTML response (a media file, JSON) is refused with a hint to pass the URL without
page:.
The host check reads the hostname only. It cannot see a public name that resolves to a private address (DNS rebinding); roadmap item 10 adds that check to a shared hardened fetch.
Vimeo, Loom and Wistia#
Status verified live on 2026-11-14, keyless, from a Google Cloud VM.
| Platform | Shipped | Metadata | Transcript |
|---|---|---|---|
| Vimeo | yes: vimeo, vimeo-captions |
oEmbed and the player config: title, owner, duration, thumbnail, upload date | the player config's WebVTT text_tracks: creator tracks as creator-captions, Vimeo's speech recognition as auto-captions |
| Loom | yes: loom, loom-captions |
the public share page's __APOLLO_STATE__: title, owner, duration, chapters, created time |
the WebVTT captions file the share page links to, made by Loom's speech recognition (auto-captions) |
| Wistia | yes: wistia, wistia-captions |
fast.wistia.com/embed/medias/<id>.json: title, duration, created time, thumbnail |
fast.wistia.com/embed/captions/<id>.json: every published track with timed lines (creator-captions) |
Vimeo#
Accepted URLs: vimeo.com/<id>, unlisted vimeo.com/<id>/<hash>, player.vimeo.com/video/<id> (with ?h=<hash> for unlisted), vimeo.com/channels/<name>/<id>, vimeo.com/groups/<name>/videos/<id>, vimeo.com/showcase/<id>/video/<id> and vimeo.com/album/<id>/video/<id>. A plain Vimeo URL resolves to vimeo, not to yt-dlp, and a video with creator captions never costs a speech-to-text call. When there are no captions, yt-dlp supplies the audio.
Track choice: with --lang, a creator track in that language, then (unless auto-captions are turned off) an auto track in it. Without --lang, the video's locale is taken as the spoken language; then the track Vimeo marks default, then the first creator track.
Errors: a deleted video is not-found; a private or password video is unavailable (pass the full unlisted link with its hash if you have it).
From datacenter IPs, Vimeo answers the player config with a 403 bot check for many videos outside enterprise accounts ("We're having a little trouble"). oEmbed still works there, so metadata falls back to oEmbed (marked extra.configBlocked), and captions report blocked; the pipeline then moves on to yt-dlp audio. Running on your own machine avoids the bot check.
Loom#
Accepted URLs: loom.com/share/<id>, loom.com/share/<title-slug>-<id> and loom.com/embed/<id>. Works for videos shared with anyone with the link. Loom's captions are machine-made, so a request with auto-captions turned off (allowAuto: false) skips them, and a --lang other than the video's language returns none.
Wistia#
Accepted URLs: fast.wistia.net/embed/iframe/<id>, fast.wistia.com/embed/iframe/<id>, fast.wistia.com/embed/medias/<id> and <account>.wistia.com/medias/<id> (also wi.st). Wistia publishes neither the origin of a track nor the spoken language, so tracks are reported as creator captions and, with several tracks and no --lang, English is used when present, otherwise the first track. Extended audio descriptions (lines describing the picture) are dropped.