$npx -y skills add LjyYano/skill-pack --skill link-to-noteUse when the user provides a YouTube, Bilibili, Apple Podcasts, Xiaoyuzhou (小宇宙), or any yt-dlp-compatible URL and wants it summarized into a rich Obsidian note. Videos with auto-captions use the subtitle path (free, no ASR call). Audio URLs and videos without subtitles use DashS
| 1 | # Link → Obsidian Note |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | 统一的「URL → Obsidian 笔记」工作流,覆盖视频(YouTube / Bilibili)与音频(Apple Podcasts / 小宇宙 / 其他 yt-dlp 可抓取的音频链接)。替代并合并了旧的 `video-to-note` 与 `podcast-to-note`。 |
| 6 | |
| 7 | **Transcript pipeline — 字幕优先、ASR 兜底:** |
| 8 | |
| 9 | | 输入 | 路径 | 转录方式 | |
| 10 | | --- | --- | --- | |
| 11 | | YouTube / Bilibili,有 auto/manual subs | 字幕路径 | yt-dlp 下载字幕 + 本地解析(无 ASR 调用) | |
| 12 | | YouTube / Bilibili,无字幕 | ASR 路径 | paraformer-v2 异步转写(带 sentence 时间戳) | |
| 13 | | Apple Podcasts / 小宇宙 / 其他音频 URL | ASR 路径 | 同上 | |
| 14 | |
| 15 | 不论哪条路径,最终都归一到 `[{begin_time_ms, text}, ...]` 段落列表,后续组织笔记逻辑完全共用。 |
| 16 | |
| 17 | **Output directory:** |
| 18 | - YouTube → `AI/YouTube/` |
| 19 | - Bilibili → `AI/Bilibili/` |
| 20 | - Apple Podcasts / 小宇宙 → `AI/Podcasts/` |
| 21 | - 其他 yt-dlp 音频 → `AI/Audio/` |
| 22 | |
| 23 | > **不标注说话人。** 过往尝试 ASR diarization 和从 shownotes 推断都不稳定,转录一律只保留 `**[MM:SS]** 文本`。详见 `feedback_podcast_no_speaker` 记忆。 |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ## Prerequisites |
| 28 | |
| 29 | - `yt-dlp` — YouTube 元数据 + 字幕 / 音频下载;Apple Podcasts URL 通常会被解析到 xiaoyuzhoufm CDN m4a |
| 30 | - `python3` + `requests` — Bilibili REST API(yt-dlp 对 Bilibili 返回 412,不可用)+ DashScope API 调用 |
| 31 | - `ALIYUN_API_KEY` — DashScope API key(paraformer-v2 异步转写) |
| 32 | - 不依赖 Obsidian CLI,直接 Write 文件即可(Obsidian 会自动索引) |
| 33 | |
| 34 | > **ffmpeg 不再需要。** paraformer-v2 处理整段音频,不需要本地分片。旧版 `qwen3-asr-flash` + chunking 的路径已淘汰。 |
| 35 | |
| 36 | --- |
| 37 | |
| 38 | ## Workflow |
| 39 | |
| 40 | ### 0. 平台检测 + 路由 |
| 41 | |
| 42 | ```python |
| 43 | import re, hashlib |
| 44 | |
| 45 | url = "URL" |
| 46 | if re.search(r'(youtube\.com|youtu\.be)', url): |
| 47 | platform, has_video, output_dir = "youtube", True, "AI/YouTube" |
| 48 | elif re.search(r'(bilibili\.com|b23\.tv)', url): |
| 49 | platform, has_video, output_dir = "bilibili", True, "AI/Bilibili" |
| 50 | elif re.search(r'podcasts\.apple\.com', url): |
| 51 | platform, has_video, output_dir = "apple-podcasts", False, "AI/Podcasts" |
| 52 | elif re.search(r'xiaoyuzhoufm\.com', url): |
| 53 | platform, has_video, output_dir = "xiaoyuzhou", False, "AI/Podcasts" |
| 54 | else: |
| 55 | platform, has_video, output_dir = "generic", False, "AI/Audio" |
| 56 | |
| 57 | slug = hashlib.md5(url.encode()).hexdigest()[:10] # short slug for temp files |
| 58 | ``` |
| 59 | |
| 60 | ### 1. 元数据 + 字幕可用性 |
| 61 | |
| 62 | > **⚠️ Bilibili 不走 yt-dlp。** yt-dlp 对 Bilibili 返回 `HTTP Error 412: Precondition Failed`(截至 2026.03.17 版本,加 cookies / headers / extractor-args 均无效),必须用 Bilibili REST API。YouTube 和其他平台仍用 yt-dlp。 |
| 63 | |
| 64 | #### 1A. Bilibili 路径(REST API 直取) |
| 65 | |
| 66 | ```python |
| 67 | import re, requests, json |
| 68 | from datetime import datetime |
| 69 | |
| 70 | url = "URL" |
| 71 | bvid_match = re.search(r'(BV[a-zA-Z0-9]+)', url) |
| 72 | bvid = bvid_match.group(1) |
| 73 | headers = { |
| 74 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
| 75 | 'Referer': 'https://www.bilibili.com' |
| 76 | } |
| 77 | |
| 78 | # 1) 元数据 |
| 79 | info = requests.get(f'https://api.bilibili.com/x/web-interface/view?bvid={bvid}', |
| 80 | headers=headers, timeout=15).json()['data'] |
| 81 | TITLE = info['title'] |
| 82 | CHANNEL = info['owner']['name'] |
| 83 | UPLOAD_DATE = datetime.fromtimestamp(info['pubdate']).strftime('%Y%m%d') |
| 84 | DURATION_SEC = info['duration'] |
| 85 | DURATION = f"{DURATION_SEC // 60}:{DURATION_SEC % 60:02d}" |
| 86 | DESCRIPTION = info.get('desc', '') |
| 87 | CID = info['cid'] |
| 88 | |
| 89 | # 2) 字幕检测 |
| 90 | player = requests.get(f'https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={CID}', |
| 91 | headers=headers, timeout=15).json() |
| 92 | subtitles = player.get('data', {}).get('subtitle', {}).get('subtitles', []) |
| 93 | # subtitles 非空 → 有字幕(B站字幕通常只有 CC 字幕) |
| 94 | # subtitles 为空 → NO_SUBS,走 ASR |
| 95 | ``` |
| 96 | |
| 97 | **Bilibili 音频下载(Step 2B 需要时):** |
| 98 | |
| 99 | ```python |
| 100 | # 获取音频流 URL |
| 101 | playurl = requests.get( |
| 102 | f'https://api.bilibili.com/x/player/playurl?bvid={bvid}&cid={CID}&qn=64&fnval=16', |
| 103 | headers=headers, timeout=15 |
| 104 | ).json()['data']['dash']['audio'] |
| 105 | best_audio = max(playurl, key=lambda x: x.get('bandwidth', 0)) |
| 106 | audio_url = best_audio['baseUrl'] |
| 107 | |
| 108 | # 下载 m4s(实际 m4a 兼容,可直接送 paraformer-v2) |
| 109 | audio_resp = requests.get(audio_url, headers=headers, timeout=120, stream=True) |
| 110 | with open(f'./.ltn_audio_{slug}.m4a', 'wb') as f: |
| 111 | for chunk in audio_resp.iter_content(chunk_size=8192): |
| 112 | f.write(chunk) |
| 113 | ``` |
| 114 | |
| 115 | **Bilibili 字幕下载(有字幕时):** 字幕 URL 在 `subtitles[].subtitle_url` 中,需要补 `https:` 前缀,下载后为 JSON 格式(`body` 数组,每项含 `from`, `to`, `content`),直接解析即可,无需 SRT/VTT 解析器。 |
| 116 | |
| 117 | #### 1B. YouTube / 其他平台路径(yt-dlp) |
| 118 | |
| 119 | ```bash |
| 120 | yt-dlp --dump-json --skip-download "URL" 2>&1 | python3 -c " |
| 121 | import json, sys |
| 122 | for line in sys.stdin.read().strip().split('\n'): |
| 123 | try: |
| 124 | d = json.loads(line) |
| 125 | print('TITLE:', d.get('title') or d.get('episode','')) |
| 126 | print('CHANNEL:', d.get('channel','') or d.get('uploader','') or d.get('series','')) |
| 127 | print('UPLOAD_DATE:', d.get('upload_date','')) |
| 128 | print('DURATION:', d.get('du |