Skip to content
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
204 changes: 204 additions & 0 deletions factory/factory-playwright.js
Original file line number Diff line number Diff line change
@@ -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);
})();
11 changes: 11 additions & 0 deletions factory/factory-playwright.json
Original file line number Diff line number Diff line change
@@ -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"
}
15 changes: 15 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
19 changes: 19 additions & 0 deletions schemas/factory.artists.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
45 changes: 45 additions & 0 deletions schemas/factory.logs.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
24 changes: 24 additions & 0 deletions schemas/factory.profile.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}