Symptom: In Obsidian Web Clipper on iOS Safari, {{transcript}} is always empty for YouTube videos, while the same template works on macOS (www.youtube.com). Previously reported as symptoms in obsidianmd/obsidian-clipper#728 and obsidianmd/obsidian-clipper#758, but the underlying fetch bug was never identified. Verified against current main (9db7260).
Root cause
On iPhone, Safari is redirected to m.youtube.com, so the caption XML fetch to https://www.youtube.com/api/timedtext?... is cross-origin. fetchCaptionXml attaches a custom header:
https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L585
const captionHeaders: Record<string, string> = { 'User-Agent': 'Mozilla/5.0' };
User-Agent is no longer a forbidden header in WebKit, so the browser does not strip it. On a cross-origin request it is a non-safelisted header, which forces a CORS preflight. The timedtext endpoint's preflight response contains no Access-Control-Allow-Headers, so the request is blocked and the fetch rejects with TypeError: Load failed. The transcript then falls through to the mobile DOM-clicking fallback, which frequently fails (see "Secondary issues" below), so the user gets an empty transcript.
On desktop (www.youtube.com) the same fetch is same-origin, CORS never applies, and everything works. That is the entire macOS/iOS asymmetry.
Reproduction (Playwright WebKit 26.4, which is the iOS Safari engine)
On a loaded https://m.youtube.com/watch?v=dQw4w9WgXcQ page, in page context:
| Step (mirrors youtube.ts logic) |
Result |
InnerTube IOS client POST to /youtubei/v1/player |
PASS, status 200, 6 captionTracks |
fetch(track.baseUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }) |
FAIL, TypeError: Load failed |
Identical fetch(track.baseUrl) with no headers |
PASS, status 200, 4555 bytes of transcript XML |
Preflight evidence via curl (note: no Access-Control-Allow-Headers in the response, so the requested user-agent header is not approved):
$ curl -X OPTIONS "https://www.youtube.com/api/timedtext?..." \
-H "Origin: https://m.youtube.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: user-agent" -D -
HTTP/2 200
access-control-allow-origin: https://m.youtube.com
access-control-allow-credentials: true
Suggested fix
Only set the User-Agent header outside browser contexts (it exists for the server-side CLI; in a browser the real UA is sent anyway and the custom value is at best ignored):
const captionHeaders: Record<string, string> = {};
if (typeof window === 'undefined') {
captionHeaders['User-Agent'] = 'Mozilla/5.0';
}
The same consideration applies to the Android InnerTube client path in fetchPlayerData, which also sends a custom User-Agent and cannot pass preflight from m.youtube.com (the player endpoint's preflight only allows content-type). The IOS client path is unaffected since Content-Type: application/json is approved.
Secondary issues found while diagnosing (can split into separate issues if preferred)
- Relative inline
baseUrl on m.youtube.com. The mobile page's ytInitialPlayerResponse caption baseUrl is relative (/api/timedtext?...), so the SSRF guard new URL(track.baseUrl) throws and kills the inline path even though that fetch would be same-origin on mobile. Fix: new URL(track.baseUrl, 'https://www.youtube.com') (https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L582).
- Mobile DOM fallback rarely fires.
openMobileTranscriptPanel requires button[aria-label="View all"], which only exists when the video has chapters, and the full chain (3 sequential 4s API attempts plus up to 5s polling per step) can exceed Obsidian Web Clipper's 8s parseAsync race, after which the sync fallback drops async variables like {{transcript}} while all sync variables fill in normally. That matches the "everything but the transcript" reports exactly.
Happy to submit a PR for the User-Agent and relative-URL fixes if useful.
Symptom: In Obsidian Web Clipper on iOS Safari,
{{transcript}}is always empty for YouTube videos, while the same template works on macOS (www.youtube.com). Previously reported as symptoms in obsidianmd/obsidian-clipper#728 and obsidianmd/obsidian-clipper#758, but the underlying fetch bug was never identified. Verified against currentmain(9db7260).Root cause
On iPhone, Safari is redirected to
m.youtube.com, so the caption XML fetch tohttps://www.youtube.com/api/timedtext?...is cross-origin.fetchCaptionXmlattaches a custom header:https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L585
User-Agentis no longer a forbidden header in WebKit, so the browser does not strip it. On a cross-origin request it is a non-safelisted header, which forces a CORS preflight. The timedtext endpoint's preflight response contains noAccess-Control-Allow-Headers, so the request is blocked and the fetch rejects withTypeError: Load failed. The transcript then falls through to the mobile DOM-clicking fallback, which frequently fails (see "Secondary issues" below), so the user gets an empty transcript.On desktop (www.youtube.com) the same fetch is same-origin, CORS never applies, and everything works. That is the entire macOS/iOS asymmetry.
Reproduction (Playwright WebKit 26.4, which is the iOS Safari engine)
On a loaded
https://m.youtube.com/watch?v=dQw4w9WgXcQpage, in page context:IOSclient POST to/youtubei/v1/playerfetch(track.baseUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } })TypeError: Load failedfetch(track.baseUrl)with no headersPreflight evidence via curl (note: no
Access-Control-Allow-Headersin the response, so the requesteduser-agentheader is not approved):Suggested fix
Only set the
User-Agentheader outside browser contexts (it exists for the server-side CLI; in a browser the real UA is sent anyway and the custom value is at best ignored):The same consideration applies to the Android InnerTube client path in
fetchPlayerData, which also sends a customUser-Agentand cannot pass preflight fromm.youtube.com(the player endpoint's preflight only allowscontent-type). The IOS client path is unaffected sinceContent-Type: application/jsonis approved.Secondary issues found while diagnosing (can split into separate issues if preferred)
baseUrlon m.youtube.com. The mobile page'sytInitialPlayerResponsecaptionbaseUrlis relative (/api/timedtext?...), so the SSRF guardnew URL(track.baseUrl)throws and kills the inline path even though that fetch would be same-origin on mobile. Fix:new URL(track.baseUrl, 'https://www.youtube.com')(https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L582).openMobileTranscriptPanelrequiresbutton[aria-label="View all"], which only exists when the video has chapters, and the full chain (3 sequential 4s API attempts plus up to 5s polling per step) can exceed Obsidian Web Clipper's 8sparseAsyncrace, after which the sync fallback drops async variables like{{transcript}}while all sync variables fill in normally. That matches the "everything but the transcript" reports exactly.Happy to submit a PR for the User-Agent and relative-URL fixes if useful.