效率工具

用 Vimeo oEmbed 生成一个静态视频合集页

用 Vimeo oEmbed 生成一个静态视频合集页:在 urls.txt 里列出视频地址,脚本调用 oEmbed 取标题、作者、时长和缩略图,生成 gallery.html。页面初始只显示缩略图,点击后才加载播放器。打不开的视频会被跳过,并说明原因。不需要 token。

BINBHO · 图文教程

用 Vimeo oEmbed 生成一个静态视频合集页:在 urls.txt 里列出视频地址,脚本调用 oEmbed 取标题、作者、时长和缩略图,生成 gallery.html。页面初始只显示缩略图,点击后才加载播放器。打不开的视频会被跳过,并说明原因。不需要 token。

先看清成品

本机真实调用了 oEmbed:https://vimeo.com/22439234 返回 “The Mountain / TSO Photography / 185 秒”,https://vimeo.com/1084537 返回 “Big Buck Bunny / Blender / 597 秒”,https://vimeo.com/76979871 返回 404,被跳过。用无头 Chrome 打开生成的页面:两张缩略图都加载成功(宽 640),点击后换成了 player.vimeo.com/video/22439234?autoplay=1&dnt=1 的 iframe。播放器本身能否播放没有验证(原因见 B 篇的人机验证)。pytest 3 个测试通过。

注意:Vimeo 各会员等级的价格、存储和上传额度以官方价格页为准,本文不列数字(官方上传指南里有额度表)。只上传和嵌入你有权使用的视频;不要共享会员账号或 access token;不要绕过隐私设置、密码或禁止嵌入的限制。

做完之后你会得到什么?

成果 说明
gallery.py 只用标准库:oEmbed、缓存、生成 HTML
gallery.html 响应式网格,点击后才加载播放器
安全 标题、作者等字段都做了 HTML 转义
失败处理 403 和 404 按官方文档给出原因

需要准备哪些环境?

项目 要求 说明
Python 3.8 以上(本机 3.13) 测试要 pytest
视频地址 公开、允许嵌入 unlisted 视频要用带 h 参数的完整地址

文件结构

30-vimeo-C-src/
├── gallery.py
├── test_gallery.py
├── urls.txt
└── gallery.html      # 本机生成的结果

第 1 步:oEmbed 请求

curl 'https://vimeo.com/api/oembed.json?url=https%3A%2F%2Fvimeo.com%2F22439234&width=640'

返回的字段中,本文用到 title、author_name、author_url、duration(秒)、thumbnail_url、video_id。官方文档中的错误码:403 表示禁止嵌入;404 表示视频因隐私或权限无法访问,或者仍在转码。

第 2 步:脚本

gallery.py:

"""读取 urls.txt,用 Vimeo oEmbed 取标题、作者、时长和缩略图,生成静态 gallery.html。
页面先只显示缩略图,点击后才加载播放器(减少页面初始加载的请求)。
用法:python gallery.py urls.txt gallery.html"""
import html, json, sys, time, urllib.parse, urllib.request, urllib.error
from pathlib import Path

ENDPOINT = "https://vimeo.com/api/oembed.json"
CACHE = Path(".oembed-cache.json")
REASONS = {403: "作者禁止嵌入", 404: "视频不存在、未公开或仍在转码"}

def fetch(url, width=640, opener=urllib.request.urlopen):
    q = urllib.parse.urlencode({"url": url, "width": width, "dnt": "true"})
    req = urllib.request.Request(f"{ENDPOINT}?{q}", headers={"User-Agent": "oembed-gallery/1.0"})
    try:
        with opener(req, timeout=15) as r:
            return json.load(r), None
    except urllib.error.HTTPError as e:
        return None, f"HTTP {e.code}:{REASONS.get(e.code, '未知错误')}"

def mmss(sec):
    m, s = divmod(int(sec or 0), 60); h, m = divmod(m, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"

def card(d):
    e = lambda s: html.escape(str(s), quote=True)
    vid = int(d["video_id"])
    return f'''<figure class="card" data-id="{vid}">
  <button class="thumb" aria-label="播放 {e(d["title"])}"><img src="{e(d["thumbnail_url"])}" alt="" loading="lazy"><span>{mmss(d.get("duration"))}</span></button>
  <figcaption><b>{e(d["title"])}</b><br><a href="{e(d["author_url"])}" rel="noopener">{e(d["author_name"])}</a></figcaption>
</figure>'''

PAGE = """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>视频合集</title>
<style>body{{font-family:sans-serif;max-width:1000px;margin:24px auto}}.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}}
.thumb{{position:relative;padding:0;border:0;cursor:pointer;width:100%;aspect-ratio:16/9;background:#000}}.thumb img{{width:100%;height:100%;object-fit:cover}}
.thumb span{{position:absolute;right:6px;bottom:6px;background:#000c;color:#fff;padding:2px 6px;font-size:12px}}iframe{{width:100%;aspect-ratio:16/9;border:0}}</style></head>
<body><h1>视频合集</h1><div class="grid">
{cards}
</div>
<script>
document.querySelectorAll(".card .thumb").forEach(btn => btn.addEventListener("click", () => {{
  const id = btn.closest(".card").dataset.id;
  const f = document.createElement("iframe");
  f.src = "https://player.vimeo.com/video/" + id + "?autoplay=1&dnt=1";
  f.allow = "autoplay; fullscreen; picture-in-picture"; f.title = btn.getAttribute("aria-label");
  btn.replaceWith(f);
}}));
</script></body></html>"""

def build(urls, fetcher=fetch, cache=None):
    cache = {} if cache is None else cache
    cards, problems = [], []
    for u in urls:
        if u not in cache:
            data, err = fetcher(u)
            if err: problems.append(f"{u} → {err}"); continue
            cache[u] = data; time.sleep(0.2)
        cards.append(card(cache[u]))
    return PAGE.format(cards="\n".join(cards)), problems, cache

if __name__ == "__main__":
    urls = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip() and not l.startswith("#")]
    cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
    page, problems, cache = build(urls, cache=cache)
    Path(sys.argv[2]).write_text(page, encoding="utf-8"); CACHE.write_text(json.dumps(cache, ensure_ascii=False, indent=1))
    print(f"{len(urls) - len(problems)} / {len(urls)} 个视频写入 {sys.argv[2]}")
    for p in problems: print("SKIP", p)

要点:

  • 不用 oEmbed 返回的 html 字段,而是自己用 video_id 拼 iframe,这样就能先显示缩略图,点击后再加载播放器。
  • 外部返回的标题等文字一律用 html.escape 转义后再写进页面。
  • 结果缓存在 .oembed-cache.json 里,重复运行不会重复请求。官方说明视频更新后,oEmbed 可能要几分钟才反映变化;需要刷新时删掉缓存即可。

第 3 步:测试

test_gallery.py:

from gallery import build, mmss

def fake(u):
    if u.endswith("/404"): return None, "HTTP 404:视频不存在、未公开或仍在转码"
    return {"video_id": 1, "title": 'A <script>"x"</script>', "author_name": "Me", "author_url": "https://vimeo.com/me",
            "thumbnail_url": "https://i.vimeocdn.com/video/1_640", "duration": 3725}, None

def test_escape_and_skip():
    page, problems, cache = build(["https://vimeo.com/1", "https://vimeo.com/404"], fetcher=fake)
    assert "<script>\"x\"" not in page and "&lt;script&gt;" in page
    assert len(problems) == 1 and "404" in problems[0] and "1:02:05" in page

def test_cache_hit():
    calls = []
    f = lambda u: (calls.append(u), fake(u))[1]
    cache = {}
    build(["https://vimeo.com/1"], fetcher=f, cache=cache); build(["https://vimeo.com/1"], fetcher=f, cache=cache)
    assert len(calls) == 1

def test_mmss():
    assert mmss(596) == "9:56" and mmss(None) == "0:00"
python -m pytest -q     # 3 passed

第 4 步:生成

urls.txt:

# 每行一个 Vimeo 地址;只放你有权展示、允许嵌入的视频
https://vimeo.com/22439234
https://vimeo.com/1084537
https://vimeo.com/76979871
$ python gallery.py urls.txt gallery.html
2 / 3 个视频写入 gallery.html
SKIP https://vimeo.com/76979871 → HTTP 404:视频不存在、未公开或仍在转码

检查结果:用浏览器打开 gallery.html,能看到两张卡片,时长分别是 3:05 和 9:57;点击缩略图后变成播放器。

调试示例

示例视频返回 404

最初准备用 Player.js 文档里常见的示例视频 76979871,以及另外两个 ID,结果三个都返回 404 Not Found。同一时间 vimeo.com 首页返回 200,说明网络没有问题。按 oEmbed 文档的说法,404 表示视频因为隐私或权限无法访问,或者还在转码,旧教程里的示例视频可能已经被设为私享或删除了。最后换成两个当前能正常访问的公开视频,并让脚本对 404 跳过并说明原因,不让整个流程中断。

常见问题

现象 原因 处理
403 作者禁止嵌入 不要嵌入,换成链接
返回内容很少,带 domain_status_code: 403 视频限制了嵌入域名 官方的做法是带上允许域名的 Referer 请求;只对你自己的视频这样做
unlisted 视频返回 404 地址里缺少 h 参数那一段 用完整的分享地址
标题没有更新 命中了本地缓存,或者 oEmbed 还没同步 删除缓存,过几分钟再试

模板规则

  • 只嵌入允许嵌入的公开视频,并保留作者署名和链接。
  • 外部数据转义后再输出到页面。
  • 先显示缩略图,点击后再加载播放器。
  • 有视频失败时跳过并说明原因,不要让整个页面生成失败。

项目资源

用途 地址
oEmbed 文档(参数、字段、错误码) https://developer.vimeo.com/api/oembed/videos
隐私与嵌入设置 https://developer.vimeo.com/api/guides/videos/interact
Python html 模块 https://docs.python.org/3/library/html.html
Vimeo 服务条款 https://vimeo.com/terms

oEmbed 不需要 token,适合做合集页、文章嵌入这类只读场景。自己控制嵌入代码,先显示缩略图,页面加载更快,也更容易做好转义和错误处理。