Skip to content
This repository was archived by the owner on Jul 18, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 56 additions & 42 deletions connectors/google/youtube-playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ const checkLoginStatus = async () => {

const settleLoggedInSession = async () => {
try {
// Wait for the topbar to render a decisive element (avatar OR sign-in link)
// before judging. Otherwise a freshly-loaded YouTube page reports
// "not logged in" simply because the avatar button hasn't mounted yet —
// which is exactly how a successful manual login gets misread as a failure
// right after the headed→headless switch.
await waitForYoutubeLoginSurface(8000);
let state = await getYoutubeSessionState();

if (state.hasAccountMenu && !state.hasSignInLink) {
Expand Down Expand Up @@ -807,81 +813,72 @@ const scrapeHistory = async () => {
if (results.length >= ${MAX_HISTORY_ITEMS}) break;

// Section date header: "Today", "Yesterday", "Jan 23, 2026", etc.
// New DOM: ytd-item-section-header-renderer > #header > #title (a plain div)
// Current DOM renders it as ytd-item-section-header-renderer > h2.
const headerTitleEl = section.querySelector(
'ytd-item-section-header-renderer #header #title, ' +
'ytd-item-section-header-renderer h2, ' +
'ytd-item-section-header-renderer #title, ' +
'#header #title'
);
const watchedAtText = headerTitleEl
? (headerTitleEl.textContent || '').trim() || null
: null;

// History page now uses yt-lockup-view-model (new renderer)
// Each lockup has class "content-id-{videoId}" for easy extraction
// Each video is a yt-lockup-view-model. YouTube obfuscates the inner
// class names (yt-lockup-view-model__*, content-id-*), so extract via
// STABLE structural selectors: the /watch href, the h3 title, and the
// channel aria-label. (The old class-based selectors silently matched
// nothing, so every item was skipped → 0 history captured.)
const lockups = section.querySelectorAll('yt-lockup-view-model');

for (const lockup of lockups) {
if (results.length >= ${MAX_HISTORY_ITEMS}) break;

// videoId from the content-id-* class
const contentIdClass = Array.from(lockup.classList)
.find(c => c.startsWith('content-id-'));
const videoId = contentIdClass
? contentIdClass.replace('content-id-', '')
: null;

// Video URL — thumbnail anchor or title anchor
const linkEl = lockup.querySelector(
'a.yt-lockup-view-model__content-image, ' +
'a.yt-lockup-metadata-view-model__title'
);
// Link to the video — the /watch anchor, not an obfuscated class.
const linkEl = lockup.querySelector('a[href*="/watch"]');
if (!linkEl) continue;

const href = linkEl.getAttribute('href') || '';
const videoUrl = href.startsWith('http')
? href
: 'https://www.youtube.com' + href;

// videoId from ?v=, falling back to the legacy content-id-* class.
const vMatch = href.match(/[?&]v=([^&]+)/);
const contentIdClass = Array.from(lockup.classList)
.find(c => c.startsWith('content-id-'));
const videoId = vMatch
? vMatch[1]
: (contentIdClass ? contentIdClass.replace('content-id-', '') : null);

// De-dup across scroll re-evaluations
const key = videoId || videoUrl;
if (seen.has(key)) continue;
seen.add(key);

// Title: prefer h3[title] attribute (full, untruncated)
const titleEl = lockup.querySelector(
'h3.yt-lockup-metadata-view-model__heading-reset'
);
// Title: the h3's title attribute (full, untruncated) or its text.
const titleEl = lockup.querySelector('h3[title], h3');
const videoTitle = titleEl
? (titleEl.getAttribute('title') || titleEl.textContent || '').trim() || null
: null;

// Channel name: from avatar aria-label ("Go to channel X")
// Channel name: from avatar/link aria-label ("Go to channel X").
const avatarEl = lockup.querySelector('[aria-label^="Go to channel"]');
const channelTitle = avatarEl
? (avatarEl.getAttribute('aria-label') || '')
.replace(/^Go to channel\\s+/i, '').trim() || null
: null;

// Views: last .metadata-text span inside the first padded row
const firstRow = lockup.querySelector(
'.yt-content-metadata-view-model__metadata-row--metadata-row-padding'
);
// Views: a metadata span ending in "views" (best-effort).
let views = null;
if (firstRow) {
const metaSpans = firstRow.querySelectorAll(
'.yt-content-metadata-view-model__metadata-text'
);
const lastSpan = metaSpans[metaSpans.length - 1];
if (lastSpan) views = (lastSpan.textContent || '').trim() || null;
const metaSpans = lockup.querySelectorAll(
'span[role="text"], .yt-content-metadata-view-model__metadata-text'
);
for (const sp of metaSpans) {
const t = (sp.textContent || '').trim();
if (/\\bviews?\\b/i.test(t)) { views = t; break; }
}

// Description: multi-line text snippet
const descEl = lockup.querySelector(
'.yt-content-metadata-view-model__metadata-text-max-lines-2'
);
const description = descEl
? (descEl.textContent || '').trim() || null
: null;
const description = null;

if (!videoTitle && !videoId) continue;

Expand Down Expand Up @@ -1175,7 +1172,14 @@ const scrapeHistory = async () => {
);
await page.goHeadless({ resumeUrl: 'https://www.youtube.com/' });
}
state.isLoggedIn = await settleLoggedInSession();
// Retry instead of judging on the first frame — mirrors the programmatic
// path above. The headed→headless switch + resume needs a beat for the
// signed-in topbar to re-render.
for (let i = 0; i < 10; i++) {
state.isLoggedIn = await settleLoggedInSession();
if (state.isLoggedIn) break;
await waitForYoutubeLoginSurface(2000);
}
}

await page.setData('status', 'Login completed');
Expand All @@ -1185,7 +1189,13 @@ const scrapeHistory = async () => {

if (!state.isLoggedIn) {
await page.setData('error', 'Login failed or timed out');
return { error: 'Login failed or timed out' };
// Include the required `timestamp` so this surfaces as a clean login
// failure, not a "Protocol violation: timestamp is required".
return {
success: false,
error: 'Login failed or timed out',
timestamp: new Date().toISOString(),
};
}

// ═══ Phase 1: Extract Email ═══
Expand Down Expand Up @@ -1424,7 +1434,7 @@ const scrapeHistory = async () => {
};
})(),
timestamp: new Date().toISOString(),
version: '1.0.0-playwright',
version: '1.0.1-playwright',
platform: 'youtube'
};
};
Expand All @@ -1446,6 +1456,10 @@ const scrapeHistory = async () => {

} catch (error) {
await page.setData('error', error.message);
return { success: false, error: error.message };
return {
success: false,
error: error.message,
timestamp: new Date().toISOString(),
};
}
})();
2 changes: 1 addition & 1 deletion connectors/google/youtube-playwright.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": "1.0",
"connector_id": "youtube-playwright",
"source_id": "youtube",
"version": "1.0.0",
"version": "1.0.1",
"name": "YouTube",
"company": "Google",
"description": "Exports your YouTube profile, subscriptions, playlists, playlist items, liked videos, watch later list, and top 50 recent watch history entries.",
Expand Down
8 changes: 4 additions & 4 deletions registry.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "2.0.0",
"lastUpdated": "2026-04-13T00:00:00Z",
"lastUpdated": "2026-06-01T00:00:00Z",
"baseUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors",
"connectors": [
{
Expand Down Expand Up @@ -150,7 +150,7 @@
{
"id": "youtube-playwright",
"company": "google",
"version": "1.0.0",
"version": "1.0.1",
"name": "YouTube",
"status": "beta",
"description": "Exports your YouTube profile, subscriptions, playlists, playlist items, liked videos, watch later list, and top 50 recent watch history entries.",
Expand All @@ -166,8 +166,8 @@
"metadata": "google/youtube-playwright.json"
},
"checksums": {
"script": "sha256:41edd09ec113ad7f9d09c9c0de76e40577087ab4600d2c8ccba4ca95c3f919c6",
"metadata": "sha256:8af6a9623851d2f0db9f86cdce50a3e60f2fee5387c5922f11c064043298ec6f"
"script": "sha256:be74afb1da9255228cf49a483695286e1b094e52afe764810d415ff0cfeb8284",
"metadata": "sha256:54055d593846ecc8abb5e366344d68ba80537a1f3409733626e227c3a289a79c"
}
},
{
Expand Down
Loading