Compare commits

...

10 Commits

9 changed files with 640 additions and 335 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
content.js

View File

@ -35,7 +35,5 @@ There's a `<-` arrow on the top right you can click to open the sidebar if it's
If you've followed the other steps, you should see some activity in the chat page's console, then see a zip file downloaded with all the artifacts correctly mapped inside. If you've followed the other steps, you should see some activity in the chat page's console, then see a zip file downloaded with all the artifacts correctly mapped inside.
## Changes under consideration ## Known bugs
1. Optionally scrape project-level artifacts instead of chat-level artifacts 1. 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.
2. Auto-open the sidebar as needed
3. Rename the zip file based on the project's name

View File

@ -2,7 +2,7 @@ browser.browserAction.onClicked.addListener((tab) => {
console.log('Extension clicked - triggering artifact collection.'); console.log('Extension clicked - triggering artifact collection.');
// Check if we're on the correct domain and path // Check if we're on the correct domain and path
if (!tab.url.startsWith('https://claude.ai/chat/')) { if (!tab.url.startsWith('https://claude.ai/chat') && !tab.url.startsWith('https://claude.ai/project')) {
console.log('Not on claude.ai chat page'); console.log('Not on claude.ai chat page');
return; return;
} }

35
bin/build Executable file
View File

@ -0,0 +1,35 @@
#!/bin/bash
set +e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$DIR/.."
# Build script to combine all source files into a single content.js
# Create the output file and start the IIFE
echo '(function() {' >content.js
echo '' >>content.js
# Add each external library first
for file in lib/*; do
cat "$file" >>content.js
echo "// === $file ===" >>content.js
echo '' >>content.js
echo '' >>content.js
done
# Add each source file in order
for file in src/*; do
echo "// === $file ===" >>content.js
cat "$file" >>content.js
echo '' >>content.js
echo '' >>content.js
done
echo 'main();' >>content.js
# Close the IIFE
echo '})();' >>content.js
echo "Built content.js from source files"

File diff suppressed because one or more lines are too long

172
src/exporter.js Normal file
View File

@ -0,0 +1,172 @@
const ArtifactExporter = {
parseFileMap(fileMap) {
const lines = fileMap.split('\n').filter(line => line.trim());
const pathMapping = {};
let currentPath = '';
lines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine.endsWith('/')) {
currentPath = trimmedLine;
} else if (trimmedLine && !trimmedLine.startsWith('/')) {
if (line.startsWith(' ')) {
pathMapping[trimmedLine] = currentPath + trimmedLine;
} else {
pathMapping[trimmedLine] = trimmedLine;
}
}
});
return pathMapping;
},
extractFilenamesFromFileMap(fileMap) {
const lines = fileMap.split('\n').filter(line => line.trim());
const filenames = [];
lines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine.endsWith('/') || !trimmedLine || trimmedLine.startsWith('/')) {
return;
}
const filename = trimmedLine.replace(/^\s+/, '');
if (filename) {
filenames.push(filename);
}
});
return filenames.sort((a, b) => b.length - a.length);
},
extractFilenameFromArtifactName(artifactName, sortedFilenames) {
for (const filename of sortedFilenames) {
const lowerArtifactName = artifactName.toLowerCase();
const lowerFilename = filename.toLowerCase();
if (lowerArtifactName.includes(lowerFilename)) {
return filename;
}
}
return artifactName;
},
createFinalMapping(artifactCollection, pathMapping) {
const finalMap = {};
const sortedFilenames = this.extractFilenamesFromFileMap(Object.keys(pathMapping).join('\n'));
Object.entries(artifactCollection).forEach(([artifactName, content]) => {
const extractedFilename = this.extractFilenameFromArtifactName(artifactName, sortedFilenames);
const fullPath = pathMapping[extractedFilename] || extractedFilename;
if (artifactName !== extractedFilename) {
console.log(`Mapped artifact "${artifactName}" -> found filename "${extractedFilename}" -> "${fullPath}"`);
} else {
console.log(`Mapped artifact "${artifactName}" -> "${fullPath}"`);
}
finalMap[fullPath] = content;
});
return finalMap;
},
toSnakeCase(str) {
return str
.replace(/([a-z])([A-Z])/g, '$1_$2') // camelCase to snake_case
.replace(/[\s\-\.]+/g, '_') // spaces, hyphens, dots to underscores
.replace(/[^\w]/g, '') // remove non-word characters
.toLowerCase() // convert to lowercase
.replace(/^_+|_+$/g, '') // trim leading/trailing underscores
.replace(/_+/g, '_'); // collapse multiple underscores
},
generateZipFilename() {
const now = new Date();
const timestamp = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0') + '_' +
String(now.getHours()).padStart(2, '0') + '-' +
String(now.getMinutes()).padStart(2, '0') + '-' +
String(now.getSeconds()).padStart(2, '0');
// Get project name and convert to snake_case
const rawProjectName = ProjectMeta.getProjectName();
const projectName = (rawProjectName && rawProjectName.trim())
? this.toSnakeCase(rawProjectName.trim())
: 'claude_artifacts';
return `${timestamp}_${projectName}.zip`;
},
addFilesToZip(zip, finalMap) {
Object.entries(finalMap).forEach(([fullPath, content]) => {
const pathParts = fullPath.split('/');
if (pathParts.length > 1) {
const filename = pathParts.pop();
const folderPath = pathParts.join('/');
const folder = zip.folder(folderPath);
folder.file(filename, content);
} else {
zip.file(fullPath, content);
}
});
},
downloadZip(content, filename) {
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log(`✓ Downloaded: ${filename}`);
},
exportArtifacts(artifactCollection, fileMap) {
// Validation
if (!artifactCollection || Object.keys(artifactCollection).length === 0) {
console.warn("No non-filemap artifacts were found. Ask the LLM to copy them from the project to this chat first.");
return;
}
if (!fileMap) {
console.warn("No filemap was found. Ask the LLM to generate one. See the README for a sample prompt.");
return;
}
// Parse the file map into paths
const pathMapping = this.parseFileMap(fileMap);
// Create final map with full paths and contents
const finalMap = this.createFinalMapping(artifactCollection, pathMapping);
// Create zip file
const zip = new JSZip();
// Add each file to the zip
this.addFilesToZip(zip, finalMap);
// Generate filename
const zipFilename = this.generateZipFilename();
// Generate and download the zip
zip.generateAsync({type: "blob"})
.then((content) => {
this.downloadZip(content, zipFilename);
})
.catch((error) => {
console.error('Failed to generate zip:', error);
});
}
};
// No exports needed - will be in same scope after build

15
src/main.js Normal file
View File

@ -0,0 +1,15 @@
async function main() {
console.log('Running artifact collector');
if (!ProjectMeta.isScrapable()) {
console.log('Not in a scrapable context, exiting');
return;
}
try {
const result = await ArtifactScraper.collectArtifacts();
ArtifactExporter.exportArtifacts(result.artifacts, result.fileMap);
} catch (error) {
console.error('Error during artifact collection and export:', error);
}
}

48
src/project_meta.js Normal file
View File

@ -0,0 +1,48 @@
const ProjectMeta = {
getProjectName() {
// First try to get from project link (chat pages)
const projectLink = document.querySelector('a[href*="/project/"]');
if (projectLink) {
// Get the full text and strip trailing spaces and forward slashes
const projectName = projectLink.innerText.replace(/\s*\/.*$/, '').trim();
if (projectName) {
return projectName;
}
}
// Fallback: try to get from H1 (project pages)
const projectH1 = document.querySelector('h1.font-ui-serif');
if (projectH1) {
return projectH1.textContent.trim() || 'Claude Project';
}
return 'Claude Project';
},
getChatName() {
const chatNameDiv = document.querySelector('button[data-testid="chat-menu-trigger"] div.truncate');
if (!chatNameDiv) {
return null;
}
return chatNameDiv.textContent.trim() || null;
},
isChat() {
return Boolean(this.getChatName());
},
isProjectPage() {
const h2Elements = document.getElementsByTagName('h2');
const hasProjectKnowledge = Array.from(h2Elements).some(h2 =>
h2.textContent.trim() === 'Project knowledge'
);
return Boolean(this.getProjectName()) && hasProjectKnowledge;
},
isScrapable() {
return this.isChat() || this.isProjectPage();
}
};
// No exports needed - will be in same scope after build

366
src/scraper.js Normal file
View File

@ -0,0 +1,366 @@
const ChatArtifactScraper = {
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) => {
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'));
return;
}
let hasResolved = false;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes);
const menuAdded = addedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.querySelector('li[role="none"] div[role="menuitem"]') ||
node.getAttribute('role') === 'menu' ||
node.querySelector('[role="menu"]'))
);
if (menuAdded) {
observer.disconnect();
if (!hasResolved) {
hasResolved = true;
resolve(true);
}
}
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
menuButton.focus();
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true
});
menuButton.dispatchEvent(enterEvent);
setTimeout(() => {
if (!hasResolved) {
observer.disconnect();
hasResolved = true;
reject(new Error('Failed to open artifact list menu - timeout'));
}
}, 3000);
});
},
getArtifactListItems() {
if (!this.getArtifactMenuButton()) {
return {};
}
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
const artifactHash = {};
if (menuItems.length === 0) {
return artifactHash;
}
menuItems.forEach((item, index) => {
const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv) {
const filename = filenameDiv.textContent.trim();
const isSelected = item.getAttribute('aria-selected') === 'true' ||
item.classList.contains('bg-accent-secondary-100') ||
item.classList.contains('border-accent-secondary-100');
artifactHash[filename] = isSelected;
console.log(`Found artifact: ${filename} (${isSelected ? 'selected' : 'unselected'})`);
}
});
return artifactHash;
},
selectArtifactListItem(filename) {
return new Promise((resolve, reject) => {
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
let targetItem = null;
menuItems.forEach(item => {
const filenameDiv = item.querySelector('div.line-clamp-2');
if (filenameDiv && filenameDiv.textContent.trim() === filename) {
targetItem = item;
}
});
if (!targetItem) {
reject(new Error(`Could not find artifact item for: ${filename}`));
return;
}
targetItem.focus();
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true
});
targetItem.dispatchEvent(enterEvent);
setTimeout(() => {
resolve(true);
}, 1000);
});
},
getArtifact(filename, isCurrentlySelected) {
return new Promise(async (resolve, reject) => {
if (!isCurrentlySelected) {
const menuOpen = document.querySelector('li[role="none"] div[role="menuitem"]');
if (!menuOpen) {
try {
await this.openArtifactList();
} catch (error) {
reject(new Error(`Failed to open menu to select: ${filename} - ${error.message}`));
return;
}
}
try {
await this.selectArtifactListItem(filename);
} catch (error) {
reject(new Error(`Failed to select artifact: ${filename} - ${error.message}`));
return;
}
}
const codeBlock = document.querySelector('div.right-0 code');
if (!codeBlock) {
reject(new Error(`No code block found for artifact: ${filename}`));
return;
}
resolve(codeBlock.textContent || codeBlock.innerText || '');
});
},
async collectArtifacts() {
const artifactCollection = {};
let fileMap = null;
await this.openArtifactList();
const artifactList = this.getArtifactListItems();
if (Object.keys(artifactList).length === 0) {
return { artifacts: artifactCollection, fileMap: fileMap };
}
console.log(`Found ${Object.keys(artifactList).length} artifacts to collect`);
for (const [filename, isSelected] of Object.entries(artifactList)) {
try {
const content = await this.getArtifact(filename, isSelected);
if (filename.toLowerCase().indexOf('files.txt') !== -1) {
fileMap = content;
console.log(`✓ Found files.txt - storing in fileMap`);
} else {
artifactCollection[filename] = content;
console.log(`✓ Collected: ${filename}`);
}
} catch (error) {
console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
return { artifacts: artifactCollection, fileMap: fileMap };
}
};
const ProjectArtifactScraper = {
getArtifactListItems() {
const gridContainer = document.querySelector('ul.grid');
const artifactHash = {};
if (!gridContainer) {
return artifactHash;
}
gridContainer.querySelectorAll('h3').forEach((titleElement) => {
const filename = titleElement.textContent.trim();
artifactHash[filename] = false;
});
return artifactHash;
},
getArtifact(filename, isCurrentlySelected) {
return new Promise((resolve, reject) => {
// Find the h3 element with the matching filename
const gridContainer = document.querySelector('ul.grid');
if (!gridContainer) {
reject(new Error('Grid container not found'));
return;
}
let targetButton = null;
gridContainer.querySelectorAll('h3').forEach((titleElement) => {
if (titleElement.textContent.trim() === filename) {
// Find the clickable button that contains this h3
targetButton = titleElement.closest('button');
}
});
if (!targetButton) {
reject(new Error(`Could not find file button for: ${filename}`));
return;
}
// Click the button to open the file modal
targetButton.click();
// Wait for modal to appear and extract content
let attempts = 0;
const maxAttempts = 30; // 3 seconds total
const handleModal = () => {
// Look for the modal dialog and the content div within it
const modalDialog = document.querySelector('div[role="dialog"][data-state="open"]');
if (modalDialog) {
const contentDiv = modalDialog.querySelector('div.whitespace-pre-wrap');
if (contentDiv) {
const content = contentDiv.textContent || contentDiv.innerText || '';
// Close the modal by clicking the close button
const closeButton = modalDialog.querySelector('button.relative.can-focus svg');
if (closeButton) {
closeButton.closest('button').click();
}
resolve(content);
return;
}
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(handleModal, 100);
} else {
reject(new Error(`Modal with content not found for artifact: ${filename}`));
}
};
// Start checking for modal after a brief delay
setTimeout(handleModal, 100);
});
},
async collectArtifacts() {
const artifactCollection = {};
let fileMap = null;
const artifactList = this.getArtifactListItems();
if (Object.keys(artifactList).length === 0) {
return { artifacts: artifactCollection, fileMap: fileMap };
}
console.log(`Found ${Object.keys(artifactList).length} artifacts to collect`);
for (const [filename, isSelected] of Object.entries(artifactList)) {
try {
const content = await this.getArtifact(filename, isSelected);
if (filename.toLowerCase().indexOf('files.txt') !== -1) {
fileMap = content;
console.log(`✓ Found files.txt - storing in fileMap`);
} else {
artifactCollection[filename] = content;
console.log(`✓ Collected: ${filename}`);
}
} catch (error) {
console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
return { artifacts: artifactCollection, fileMap: fileMap };
}
};
const ArtifactScraper = {
async collectArtifacts() {
if (ProjectMeta.isChat()) {
console.log('Detected chat - using ChatArtifactScraper');
return await ChatArtifactScraper.collectArtifacts();
} else if (ProjectMeta.isProjectPage()) {
console.log('Detected project page - using ProjectArtifactScraper');
return await ProjectArtifactScraper.collectArtifacts();
} else {
throw new Error('Unknown page type - not a chat or project page');
}
}
};
// No exports needed - will be in same scope after build