From 53df3702ea31f2fcc9fca4b73067c460446829fb Mon Sep 17 00:00:00 2001 From: Lexical Bits Date: Sun, 13 Jul 2025 17:14:51 -0400 Subject: [PATCH] Pre-open the artifact menu if it's closed --- README.md | 3 +- src/scraper.js | 85 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a9503be..8191122 100644 --- a/README.md +++ b/README.md @@ -37,5 +37,4 @@ If you've followed the other steps, you should see some activity in the chat pag ## Changes under consideration 1. Optionally scrape project-level artifacts instead of chat-level artifacts -2. Auto-open the sidebar as needed -3. Rename the zip file based on the project's name +2. Fix the scraper to work with single-artifact chats that don't have a selector button. Not sure how we'll get the artifact name here. diff --git a/src/scraper.js b/src/scraper.js index 73cbc30..31fc1f3 100644 --- a/src/scraper.js +++ b/src/scraper.js @@ -1,10 +1,61 @@ const ArtifactScraper = { - openArtifactList() { + getArtifactMenuButton() { + const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]'); + return artifactMenuButton?.closest('button'); + }, + + ensureArtifactSidebarOpen() { return new Promise((resolve, reject) => { - // Find the global artifact menu button (has the hamburger/list icon) - const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]'); - const menuButton = artifactMenuButton?.closest('button'); + if (this.getArtifactMenuButton()) { + resolve(true); + return; + } + + const toggleButton = document.querySelector('button[data-state="closed"] svg[viewBox="0 0 20 20"] path[d*="M8.14648 4.64648C8.34176 4.45136"]'); + const button = toggleButton?.closest('button'); + + if (!button) { + reject(new Error('Artifact sidebar toggle button not found')); + return; + } + + let hasResolved = false; + + const observer = new MutationObserver((mutations) => { + if (this.getArtifactMenuButton()) { + observer.disconnect(); + if (!hasResolved) { + hasResolved = true; + resolve(true); + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + button.click(); + + setTimeout(() => { + if (!hasResolved) { + observer.disconnect(); + hasResolved = true; + reject(new Error('Failed to open artifact sidebar - timeout')); + } + }, 3000); + }); + }, + + openArtifactList() { + return new Promise(async (resolve, reject) => { + try { + await this.ensureArtifactSidebarOpen(); + } catch (error) { + reject(new Error(`Failed to open artifact sidebar: ${error.message}`)); + return; + } + + const menuButton = this.getArtifactMenuButton(); if (!menuButton) { reject(new Error('Global artifact menu button not found')); @@ -13,7 +64,6 @@ const ArtifactScraper = { let hasResolved = false; - // Listen for menu opening const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { @@ -26,7 +76,6 @@ const ArtifactScraper = { ); if (menuAdded) { - console.log('Artifact list menu has been opened'); observer.disconnect(); if (!hasResolved) { hasResolved = true; @@ -39,8 +88,6 @@ const ArtifactScraper = { observer.observe(document.body, { childList: true, subtree: true }); - // Focus and send Enter key event to open menu. Click doesn't actually work, - // likely due to react pseudoevents. menuButton.focus(); const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', @@ -50,7 +97,6 @@ const ArtifactScraper = { }); menuButton.dispatchEvent(enterEvent); - // Fallback timeout so we don't accidentally listen forever. setTimeout(() => { if (!hasResolved) { observer.disconnect(); @@ -62,11 +108,14 @@ const ArtifactScraper = { }, getArtifactListItems() { + if (!this.getArtifactMenuButton()) { + return {}; + } + const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]'); const artifactHash = {}; if (menuItems.length === 0) { - console.log('No artifact list items found'); return artifactHash; } @@ -74,7 +123,6 @@ const ArtifactScraper = { const filenameDiv = item.querySelector('div.line-clamp-2'); if (filenameDiv) { const filename = filenameDiv.textContent.trim(); - // Check for aria-selected attribute or other indicators of selection const isSelected = item.getAttribute('aria-selected') === 'true' || item.classList.contains('bg-accent-secondary-100') || item.classList.contains('border-accent-secondary-100'); @@ -92,7 +140,6 @@ const ArtifactScraper = { const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]'); let targetItem = null; - // Find the matching artifact by filename menuItems.forEach(item => { const filenameDiv = item.querySelector('div.line-clamp-2'); if (filenameDiv && filenameDiv.textContent.trim() === filename) { @@ -105,11 +152,6 @@ const ArtifactScraper = { return; } - console.log(`Selecting artifact: ${filename}`); - - // Focus and send Enter key event to select the item - // Click may or may not work here, but keydown was needed elsewhere, so we're - // sticking to it here. targetItem.focus(); const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', @@ -119,7 +161,6 @@ const ArtifactScraper = { }); targetItem.dispatchEvent(enterEvent); - // Wait for artifact to load setTimeout(() => { resolve(true); }, 1000); @@ -159,17 +200,12 @@ const ArtifactScraper = { async collectArtifacts() { const artifactCollection = {}; - // There's no path information in a Claude artifact, just filenames. - // However, the LLM itself is smart enough to know where the files should go. - // So our strategy is to make it save those paths to a files.txt artifact and store - // that separately for use in making a final map of artifacts to files for our zip export. let fileMap = null; await this.openArtifactList(); const artifactList = this.getArtifactListItems(); if (Object.keys(artifactList).length === 0) { - console.log('No artifacts found in list'); return { artifacts: artifactCollection, fileMap: fileMap }; } @@ -180,7 +216,6 @@ const ArtifactScraper = { const content = await this.getArtifact(filename, isSelected); if (filename === 'files.txt') { - // This is our special llm-generated mapping that we'll need to finish our export. fileMap = content; console.log(`✓ Found files.txt - storing in fileMap`); } else { @@ -191,12 +226,10 @@ const ArtifactScraper = { console.log(`✗ Failed to collect: ${filename} - ${error.message}`); } - // Small delay between artifacts await new Promise(resolve => setTimeout(resolve, 500)); } return { artifacts: artifactCollection, fileMap: fileMap }; } }; - // No exports needed - will be in same scope after build