用 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 "<script>" 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,适合做合集页、文章嵌入这类只读场景。自己控制嵌入代码,先显示缩略图,页面加载更快,也更容易做好转义和错误处理。