diff --git a/factory/factory-playwright.js b/factory/factory-playwright.js new file mode 100644 index 0000000..e71b362 --- /dev/null +++ b/factory/factory-playwright.js @@ -0,0 +1,204 @@ +/** + * Factory.fm Vana Data Connector + * + * Exports a user's Factory.fm listening log data: + * - Profile (username, bio, karma, stats) + * - Logs (reviews with ratings, body text, release/artist details) + * - Artists (derived from logged releases) + * + * Uses Factory.fm v1 JSON API endpoints that accept session cookies. + */ + +const state = { isComplete: false }; + +// ─── Login check ──────────────────────────────────────────────────── +const checkLoginStatus = async () => { + try { + return await page.evaluate(` + (async () => { + try { + const r = await fetch('/api/auth/session', { credentials: 'include' }); + if (!r.ok) return false; + const s = await r.json(); + return !!(s.user && s.user.id); + } catch { + return false; + } + })() + `); + } catch { + return false; + } +}; + +// ─── Resolve username from nav DOM ────────────────────────────────── +const resolveUsername = async () => { + return await page.evaluate(` + (() => { + const links = Array.from(document.querySelectorAll('a[href*="/u/"]')); + for (const link of links) { + const m = link.href.match(/\\/u\\/([^/?#]+)/); + if (m) return m[1]; + } + return null; + })() + `); +}; + +// ─── Main flow ────────────────────────────────────────────────────── +(async () => { + // Phase 1: Login + await page.setData("status", "Checking login status..."); + await page.sleep(2000); + + if (!(await checkLoginStatus())) { + await page.showBrowser("https://factory.fm/login"); + await page.setData("status", "Please log in to Factory.fm..."); + await page.promptUser( + 'Please log in to your Factory.fm account. Click "Done" when ready.', + async () => await checkLoginStatus(), + 2000, + ); + } + + // Phase 2: Headless data collection + await page.goHeadless(); + + // Step 1: Resolve username + await page.setProgress({ + phase: { step: 1, total: 3, label: "Resolving profile" }, + message: "Finding your username...", + }); + + await page.goto("https://factory.fm/home"); + await page.sleep(1500); + + const username = await resolveUsername(); + if (!username) { + await page.setData("error", "Could not determine your username."); + return; + } + + // Step 1 cont: Fetch profile + await page.setProgress({ + phase: { step: 1, total: 3, label: "Resolving profile" }, + message: `Fetching profile for @${username}...`, + }); + + const profile = await page.evaluate(` + (async () => { + const r = await fetch('/api/v1/profiles/by/username/${username}', { credentials: 'include' }); + if (!r.ok) throw new Error('Profile fetch failed: ' + r.status); + return r.json(); + })() + `); + + // Step 2: Fetch all logs + await page.setProgress({ + phase: { step: 2, total: 3, label: "Fetching logs" }, + message: `Fetching logs (${profile.logCount} total)...`, + count: 0, + }); + + const allLogs = []; + let currentPage = 1; + const pageSize = 50; + let hasMore = true; + const profileId = profile.id; + + while (hasMore) { + const feedResult = await page.evaluate(` + (async () => { + const r = await fetch('/api/v1/feed/profile?profileId=${profileId}&page=${currentPage}&limit=${pageSize}', { credentials: 'include' }); + if (!r.ok) throw new Error('Feed fetch failed: ' + r.status); + return r.json(); + })() + `); + + if (feedResult.data && feedResult.data.length > 0) { + allLogs.push(...feedResult.data); + } + + hasMore = feedResult.nextPage !== null; + currentPage = feedResult.nextPage || currentPage + 1; + + await page.setProgress({ + phase: { step: 2, total: 3, label: "Fetching logs" }, + message: `Downloaded ${allLogs.length} of ~${profile.logCount} logs...`, + count: allLogs.length, + }); + + await page.sleep(300); // rate limiting + } + + // Step 3: Derive artists + await page.setProgress({ + phase: { step: 3, total: 3, label: "Processing artists" }, + message: "Extracting unique artists from logs...", + }); + + const artistMap = {}; + for (const log of allLogs) { + if (log.release && log.release.artists) { + for (const artist of log.release.artists) { + if (!artistMap[artist.id]) { + artistMap[artist.id] = { + id: artist.id, + name: artist.name, + imageUrl: artist.imageUrl || null, + }; + } + } + } + } + const artists = Object.values(artistMap); + + // Build result + const result = { + "factory.profile": { + id: profile.id, + username: profile.username, + bio: profile.bio || null, + location: profile.location || null, + imageUrl: profile.imageUrl || null, + karma: profile.karma, + tag: profile.tag || null, + logCount: profile.logCount, + followerCount: profile.followerCount, + followingCount: profile.followingCount, + createdAt: profile.createdAt, + }, + "factory.logs": allLogs.map((log) => ({ + id: log.id, + body: log.body || null, + rating: log.rating, + createdAt: log.createdAt, + release: { + id: log.release.id, + title: log.release.title, + artist: log.release.artist, + coverImage: log.release.coverImage || null, + spotifyId: log.release.spotifyId || null, + releaseDate: log.release.releaseDate || null, + slug: log.release.slug, + artists: (log.release.artists || []).map((a) => ({ + id: a.id, + name: a.name, + imageUrl: a.imageUrl || null, + })), + }, + })), + "factory.artists": artists, + exportSummary: { + count: allLogs.length, + label: allLogs.length === 1 ? "log" : "logs", + details: `${allLogs.length} logs across ${artists.length} artists`, + }, + timestamp: new Date().toISOString(), + version: "1.0.0-playwright", + platform: "factory", + }; + + state.isComplete = true; + await page.setData("result", result); +})(); diff --git a/factory/factory-playwright.json b/factory/factory-playwright.json new file mode 100644 index 0000000..9a9ed68 --- /dev/null +++ b/factory/factory-playwright.json @@ -0,0 +1,11 @@ +{ + "id": "factory-playwright", + "version": "1.0.0", + "name": "Factory.fm", + "company": "factory", + "description": "Exports your Factory.fm music listening logs, ratings, reviews, and artist data using Playwright browser automation.", + "connectURL": "https://factory.fm/login", + "connectSelector": "a[href*='/u/']", + "exportFrequency": "daily", + "runtime": "playwright" +} diff --git a/registry.json b/registry.json index 22fe7bc..dcb371c 100644 --- a/registry.json +++ b/registry.json @@ -122,6 +122,21 @@ "script": "sha256:d98a7b9bcb85a871fbc5164c34ac36369f1b7745f90bd97ae5f02c880ccacab6", "metadata": "sha256:d3ff5230a6ebe6ff3f655546d834e7ed59f741febdc540dd2f89894b6e5baa7a" } + }, + { + "id": "factory-playwright", + "company": "factory", + "version": "1.0.0", + "name": "Factory.fm", + "description": "Exports your Factory.fm music listening logs, ratings, reviews, and artist data using Playwright browser automation.", + "files": { + "script": "factory/factory-playwright.js", + "metadata": "factory/factory-playwright.json" + }, + "checksums": { + "script": "sha256:621ec69388462df4fa81f0a28e41d81e452631791553810a14be6b3f27d6ab4e", + "metadata": "sha256:db7d31afecc768bbc7aae20cf0ca1cb0b5ae15d892f3aa3f5b05dfac24eec11b" + } } ] } diff --git a/schemas/factory.artists.json b/schemas/factory.artists.json new file mode 100644 index 0000000..2d0ab74 --- /dev/null +++ b/schemas/factory.artists.json @@ -0,0 +1,19 @@ +{ + "name": "Factory.fm Artists", + "version": "1.0.0", + "scope": "factory.artists", + "dialect": "json", + "description": "Artists whose releases the user has logged on Factory.fm.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "imageUrl": { "type": ["string", "null"] } + }, + "required": ["id", "name"] + } + } +} diff --git a/schemas/factory.logs.json b/schemas/factory.logs.json new file mode 100644 index 0000000..d1bad61 --- /dev/null +++ b/schemas/factory.logs.json @@ -0,0 +1,45 @@ +{ + "name": "Factory.fm Logs", + "version": "1.0.0", + "scope": "factory.logs", + "dialect": "json", + "description": "Music review/log entries from Factory.fm with ratings, review text, and full release/artist details.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "body": { "type": ["string", "null"] }, + "rating": { "type": ["number", "null"] }, + "createdAt": { "type": "string" }, + "release": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "artist": { "type": "string" }, + "coverImage": { "type": ["string", "null"] }, + "spotifyId": { "type": ["string", "null"] }, + "releaseDate": { "type": ["string", "null"] }, + "slug": { "type": "string" }, + "artists": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "imageUrl": { "type": ["string", "null"] } + }, + "required": ["id", "name"] + } + } + }, + "required": ["id", "title", "artist"] + } + }, + "required": ["id", "rating", "createdAt", "release"] + } + } +} diff --git a/schemas/factory.profile.json b/schemas/factory.profile.json new file mode 100644 index 0000000..3d68f29 --- /dev/null +++ b/schemas/factory.profile.json @@ -0,0 +1,24 @@ +{ + "name": "Factory.fm Profile", + "version": "1.0.0", + "scope": "factory.profile", + "dialect": "json", + "description": "User profile data from Factory.fm including username, bio, karma, and activity stats.", + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "username": { "type": "string" }, + "bio": { "type": ["string", "null"] }, + "location": { "type": ["string", "null"] }, + "imageUrl": { "type": ["string", "null"] }, + "karma": { "type": "number" }, + "tag": { "type": ["string", "null"] }, + "logCount": { "type": "integer" }, + "followerCount": { "type": "integer" }, + "followingCount": { "type": "integer" }, + "createdAt": { "type": "string" } + }, + "required": ["id", "username"] + } +}