https://www.voscreen.com/ 是一个非常好的学习英语的网站,请判断和总结它是怎样实现的?如果想复刻一个该网站,需要怎么做?

https://www.voscreen.com/ 是一个非常好的学习英语的网站,请判断和总结它是怎样实现的?如果想复刻一个该网站,需要怎么做?

Voscreen 怎么工作的:

  • 本质是 SPA 单页应用,用真实影视片段(10-15秒)配合选择题,形成「看视频 → 答题 → 听原声验证」的学习闭环
  • 通过 游戏化积分 + 排行榜 + 连击机制 保持用户动力
  • 内置 自适应难度系统(A1-C2),根据答题准确率动态调整推题

复刻的关键难点(按难度排序):

  1. 🔴 内容版权与题库:视频来源合法化 + 字幕题目生成(可用 Whisper + GPT 自动化)
  2. 🟡 视频播放体验:流畅的视频播放器 + 字幕渲染 + 无缝切换体验
  3. 🟢 技术实现:React + Node.js + PostgreSQL,难度中等,一个小团队 3-6 个月可出 MVP

关于内容的思考

内容方面确实是个大问题,我是这样考虑的:现在网上有很多m3u8视频源,首先收益一些m3u8视频源(url),对这些视频进行切片,选择里面方便学习的英语(只需数据库存储时间坐标,开始和结束两个时间点),然后再考虑进行字幕生成。

AI回答

这个思路非常聪明!用 m3u8 + 时间坐标的方式,完全避开了视频存储成本,只需存元数据。让我帮你深入分析这个方案的可行性和完整实现路径。

了解 m3u8 视频切片和时间戳提取技术方案

网页搜索m3u8 视频流 ffmpeg 按时间戳截取片段 字幕提取 python

了解 Whisper 对视频片段进行字幕识别的方案

网页搜索whisper transcribe m3u8 video segment timestamp subtitle extraction python

这个方案非常可行!让我给你完整梳理技术路线:


🎯 方案总体架构

复制代码
m3u8 URL 池
    ↓
① 扫描器:分析视频,提取对话片段时间坐标
    ↓  (只存 start_time / end_time,不下载视频)
② 字幕生成器:Whisper 对片段音频识别
    ↓
③ 题目生成器:GPT 生成选择题
    ↓
④ 数据库:(m3u8_url, start, end, subtitle, choices[])
    ↓
⑤ 播放器:前端实时按时间坐标截取播放

核心优势:整个流程不需要存储一帧视频,存储成本极低!


🔧 Phase 1:视频分析 & 片段坐标提取

1.1 思路

不需要下载完整视频,只需要:

  1. 用 FFmpeg 直连 m3u8 流 → 提取音频
  2. 用 Whisper 分析音频 → 得到每句话的 [start, end, text]
  3. 筛选出"适合学习"的句子(英语、时长 5~15 秒、语速适中)

1.2 核心脚本

python

复制

复制代码
# clip_scanner.py - 扫描 m3u8,生成学习片段坐标
import subprocess
import json
import whisper
import tempfile
import os

def extract_audio_from_m3u8(m3u8_url: str, output_path: str, duration: int = 600):
    """直接从 m3u8 提取前 N 秒音频,不下载视频"""
    cmd = [
        "ffmpeg", "-y",
        "-i", m3u8_url,
        "-t", str(duration),   # 只取前 10 分钟分析
        "-vn",                 # 不要视频流
        "-acodec", "pcm_s16le",
        "-ar", "16000",        # Whisper 需要 16kHz
        "-ac", "1",            # 单声道
        output_path
    ]
    subprocess.run(cmd, capture_output=True, timeout=120)

def scan_clips(m3u8_url: str, model_size: str = "medium"):
    """扫描 m3u8 视频,返回适合学习的片段列表"""
    model = whisper.load_model(model_size)
    
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        audio_path = f.name
    
    try:
        print(f"⏬ 提取音频: {m3u8_url}")
        extract_audio_from_m3u8(m3u8_url, audio_path)
        
        print("🎤 Whisper 识别中...")
        result = model.transcribe(
            audio_path,
            language="en",
            word_timestamps=True,   # 精确到词级别时间戳
            verbose=False
        )
        
        clips = []
        for seg in result["segments"]:
            duration = seg["end"] - seg["start"]
            text = seg["text"].strip()
            
            # 筛选条件:时长 5~18 秒,英语内容,有实质内容
            if (5.0 <= duration <= 18.0 
                    and len(text.split()) >= 5   # 至少 5 个词
                    and len(text.split()) <= 30  # 不超过 30 个词
                    and seg.get("no_speech_prob", 1) < 0.3):  # 有语音
                
                clips.append({
                    "m3u8_url": m3u8_url,
                    "start": round(seg["start"], 2),
                    "end": round(seg["end"], 2),
                    "text": text,
                    "words": seg.get("words", []),  # 词级别时间戳
                    "avg_logprob": seg.get("avg_logprob", 0),  # 识别置信度
                })
        
        print(f"✅ 找到 {len(clips)} 个适合学习的片段")
        return clips
    
    finally:
        os.unlink(audio_path)  # 清理临时音频文件

🔧 Phase 2:数据库设计

sql

复制

复制代码
-- clips 表:核心片段元数据
CREATE TABLE clips (
    id          BIGSERIAL PRIMARY KEY,
    m3u8_url    TEXT NOT NULL,          -- 视频源 URL
    start_time  DECIMAL(10,2) NOT NULL, -- 开始时间(秒)
    end_time    DECIMAL(10,2) NOT NULL, -- 结束时间(秒)
    subtitle    TEXT NOT NULL,          -- Whisper 识别的英文字幕
    
    -- 学习元数据
    level       VARCHAR(2),             -- A1/A2/B1/B2/C1/C2
    word_count  SMALLINT,
    duration    DECIMAL(5,2),           -- end - start
    
    -- 状态
    status      VARCHAR(20) DEFAULT 'pending', -- pending/active/inactive
    created_at  TIMESTAMP DEFAULT NOW()
);

-- choices 表:选择题选项
CREATE TABLE clip_choices (
    id        BIGSERIAL PRIMARY KEY,
    clip_id   BIGINT REFERENCES clips(id),
    is_correct BOOLEAN NOT NULL,
    text      TEXT NOT NULL,           -- 选项文本(翻译或近义句)
    lang      VARCHAR(10) DEFAULT 'zh' -- 题目语言
);

-- m3u8_sources 表:视频源管理
CREATE TABLE m3u8_sources (
    id          BIGSERIAL PRIMARY KEY,
    url         TEXT UNIQUE NOT NULL,
    name        TEXT,                   -- 来源名称(如"老友记 S01E01")
    category    TEXT,                   -- drama/movie/documentary/talk
    is_active   BOOLEAN DEFAULT TRUE,
    last_scanned_at TIMESTAMP,
    total_clips INT DEFAULT 0,
    created_at  TIMESTAMP DEFAULT NOW()
);

-- 用户观看记录
CREATE TABLE user_clip_history (
    user_id   BIGINT,
    clip_id   BIGINT,
    answered  BOOLEAN,
    is_correct BOOLEAN,
    answered_at TIMESTAMP DEFAULT NOW(),
    PRIMARY KEY (user_id, clip_id)
);

🔧 Phase 3:GPT 自动生成选择题

python

复制

复制代码
# question_generator.py
from openai import OpenAI

client = OpenAI()

def generate_choices(subtitle: str, target_lang: str = "zh") -> list[dict]:
    """
    为字幕文本生成两个翻译选项(一对一错)
    也可以生成"意思相近的英文句子"(更高级)
    """
    prompt = f"""
你是英语学习题目生成专家。
给定一段英语对话原句,请生成两个中文翻译选项:
- 一个是正确翻译
- 一个是错误翻译(意思相近但有关键差异,不能太明显)

原句:"{subtitle}"

请返回 JSON 格式:
{{
  "correct": "正确的中文翻译",
  "distractor": "错误的中文翻译(意思相近但不准确)"
}}

要求:
1. 错误选项不能太离谱,要让人容易混淆
2. 翻译要口语化、自然
3. 只返回 JSON,不要其他内容
"""
    
    resp = client.chat.completions.create(
        model="gpt-4o-mini",  # 便宜又够用
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
        temperature=0.7
    )
    
    data = json.loads(resp.choices[0].message.content)
    return [
        {"text": data["correct"], "is_correct": True},
        {"text": data["distractor"], "is_correct": False},
    ]

🔧 Phase 4:前端播放器(关键)

前端只需要做到:按时间坐标播放 m3u8 的某一段

typescript

复制

复制代码
// VideoClipPlayer.tsx
import { useEffect, useRef } from 'react'
import Hls from 'hls.js'  // npm install hls.js

interface ClipPlayerProps {
  m3u8Url: string
  startTime: number   // 秒
  endTime: number     // 秒
  muted?: boolean     // 是否静音(答题前静音)
  onEnded?: () => void
}

export function VideoClipPlayer({ m3u8Url, startTime, endTime, muted, onEnded }: ClipPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null)
  const hlsRef = useRef<Hls | null>(null)

  useEffect(() => {
    const video = videoRef.current
    if (!video) return

    if (Hls.isSupported()) {
      const hls = new Hls({
        startPosition: startTime,  // ⭐ 直接从指定位置开始加载
      })
      hlsRef.current = hls
      hls.loadSource(m3u8Url)
      hls.attachMedia(video)
      
      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        video.currentTime = startTime
        video.play()
      })
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      // Safari 原生支持 HLS
      video.src = m3u8Url
      video.currentTime = startTime
      video.play()
    }

    return () => {
      hlsRef.current?.destroy()
    }
  }, [m3u8Url, startTime])

  useEffect(() => {
    const video = videoRef.current
    if (!video) return

    const checkEnd = () => {
      if (video.currentTime >= endTime) {
        video.pause()
        onEnded?.()
      }
    }

    video.addEventListener('timeupdate', checkEnd)
    return () => video.removeEventListener('timeupdate', checkEnd)
  }, [endTime, onEnded])

  return (
    <video
      ref={videoRef}
      muted={muted}
      playsInline
      className="w-full rounded-xl"
    />
  )
}

🔧 Phase 5:自动化扫描流水线

python

复制

复制代码
# pipeline.py - 全自动处理流水线
import asyncio
from database import db

async def process_source(source_url: str):
    """完整处理一个 m3u8 源的流水线"""
    
    # Step 1: 扫描片段
    clips = scan_clips(source_url)
    
    # Step 2: 自动评估难度
    for clip in clips:
        clip['level'] = estimate_level(clip['text'])
        clip['word_count'] = len(clip['text'].split())
        clip['duration'] = clip['end'] - clip['start']
    
    # Step 3: 批量入库
    clip_ids = await db.batch_insert_clips(clips)
    
    # Step 4: 批量生成题目(调用 GPT)
    for clip_id, clip in zip(clip_ids, clips):
        choices = generate_choices(clip['text'])
        await db.insert_choices(clip_id, choices)
    
    print(f"✅ 完成:{len(clips)} 个片段已入库")

def estimate_level(text: str) -> str:
    """简单的难度估算(可以用更复杂的模型)"""
    words = text.split()
    avg_word_len = sum(len(w) for w in words) / len(words)
    word_count = len(words)
    
    if word_count <= 8 and avg_word_len < 5:   return "A1"
    elif word_count <= 12 and avg_word_len < 6: return "A2"
    elif word_count <= 16:                      return "B1"
    elif word_count <= 22:                      return "B2"
    elif word_count <= 28:                      return "C1"
    else:                                       return "C2"

# 批量处理多个源
async def main():
    sources = [
        "https://example.com/friends_s01e01.m3u8",
        "https://example.com/ted_talk_01.m3u8",
        # ... 更多源
    ]
    
    for url in sources:
        await process_source(url)
        await asyncio.sleep(5)  # 避免过于频繁

if __name__ == "__main__":
    asyncio.run(main())

⚠️ 几个关键技术细节

问题1:m3u8 URL 会失效怎么办?

python

复制

复制代码
# 定时健康检查
async def health_check():
    sources = await db.get_all_active_sources()
    for source in sources:
        try:
            # 只检查能不能连上,不下载内容
            result = subprocess.run(
                ["ffprobe", "-v", "quiet", "-i", source.url],
                timeout=10
            )
            if result.returncode != 0:
                await db.mark_source_inactive(source.id)
        except:
            await db.mark_source_inactive(source.id)

建议:同一内容存多个备用 URL,任一失效自动切换

问题2:m3u8 seek 精度问题

m3u8 是分段文件(每段通常 2-10 秒),seek 到非关键帧会不准确

python

复制

复制代码
# 存储时记录最近的关键帧时间
def find_keyframe_time(m3u8_url: str, target_time: float) -> float:
    """找到 target_time 之前最近的关键帧"""
    result = subprocess.run([
        "ffprobe", "-v", "quiet",
        "-select_streams", "v",
        "-show_frames",
        "-read_intervals", f"%+#30",  # 分析前30帧
        "-show_entries", "frame=pkt_pts_time,key_frame",
        "-of", "csv=p=0",
        "-ss", str(max(0, target_time - 5)),
        "-i", m3u8_url
    ], capture_output=True, text=True, timeout=30)
    
    # 找最近的关键帧
    keyframes = [
        float(line.split(',')[1])
        for line in result.stdout.strip().split('\n')
        if line.startswith('1,')  # key_frame=1
    ]
    
    if keyframes:
        return min(keyframes, key=lambda x: abs(x - target_time))
    return target_time

问题3:Whisper 幻觉问题

Whisper 有时会在静音段"幻觉"出文字,用 no_speech_prob 过滤:

python

复制

复制代码
# 只保留高置信度的识别结果
if seg["no_speech_prob"] < 0.3 and seg["avg_logprob"] > -0.8:
    # 这个片段识别结果可信

📊 数据规模估算

处理量 时间 存储
1小时视频 Whisper 约 10-20 分钟 约 200-500 个片段
GPT 题目生成 约 0.01元/条 -
数据库存储 - 1万片段 ≈ 5MB(极小!)

100 部美剧 × 每集 20 分钟 × 每分钟 3 个片段 = 约 6000 个片段,成本极低。


🗺️ 建议开发顺序

复制代码
Week 1: 搭数据库 + 写 clip_scanner.py + 手动测试几个 m3u8
Week 2: 写题目生成器 + 数据入库 pipeline
Week 3: 前端播放器 + 基础学习界面
Week 4: 用户系统 + 积分 + 打磨体验

这个方案的精妙之处在于:数据库里只存 URL + 两个时间戳 + 字幕文字,视频完全托管在原始服务器,自己的服务器压力极小,理论上可以支撑海量内容。想先动手验证哪个环节?我可以帮你写完整可运行的代码。

相关推荐
后季暖2 小时前
agent学习笔记4
笔记·学习
凯尔萨厮2 小时前
Spring学习笔记(基于注解)
笔记·学习·spring
某风吾起2 小时前
通过mmwave studio配置TI毫米波雷达IWR1843的StaticConfig
嵌入式硬件·学习
EnglishJun2 小时前
ARM嵌入式学习(二十二)-- 操作系统的中断处理以及ioctl
学习
南無忘码至尊2 小时前
Unity学习90天-第3天-认识触屏输入(手游基础)并完成手机点击屏幕,物体向点击位置移动
学习·unity·c#·游戏引擎·游戏开发
青桔柠薯片3 小时前
从字符设备到平台驱动:IMX6ULL LED 与蜂鸣器驱动开发学习总结
驱动开发·学习·imx6ull
JACK的服务器笔记3 小时前
《服务器测试百日学习计划——Day19:PCIe自动检测脚本,用Python把lspci设备清点标准化》
服务器·python·学习
南無忘码至尊3 小时前
Unity学习90天-第3天-认识C# 集合与常用类并实现生成随机位置的 10 个立方体
学习·unity·c#
_李小白3 小时前
【OSG学习笔记】Day 47:相机漫游实现
笔记·数码相机·学习