# Web pages, Vimeo, Loom and Wistia

Two keyless providers cover videos that are not on YouTube:

- `providers/web-page` finds every video embedded in an article, course or landing page.
- `providers/native-transcripts` reads 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

```sh
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:

```ts
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:

```ts
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.
