Compare commits

...
10 Commits
9 changed files with 640 additions and 335 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
content.js
+2 -4
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.
## 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
## Known bugs
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.
+1 -1
View File
@@ -2,7 +2,7 @@ browser.browserAction.onClicked.addListener((tab) => {
console.log('Extension clicked - triggering artifact collection.');
// 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');
return;
}
Executable
+35
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"
-330
View File
@@ -1,4 +1,3 @@
(function() {
/*!
JSZip v3.10.1 - A JavaScript class for generating and reading zip files
@@ -12,332 +11,3 @@
*/
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e<h.length;e++)u(h[e]);return u}({1:[function(e,t,r){"use strict";var d=e("./utils"),c=e("./support"),p="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";r.encode=function(e){for(var t,r,n,i,s,a,o,h=[],u=0,l=e.length,f=l,c="string"!==d.getTypeOf(e);u<e.length;)f=l-u,n=c?(t=e[u++],r=u<l?e[u++]:0,u<l?e[u++]:0):(t=e.charCodeAt(u++),r=u<l?e.charCodeAt(u++):0,u<l?e.charCodeAt(u++):0),i=t>>2,s=(3&t)<<4|r>>4,a=1<f?(15&r)<<2|n>>6:64,o=2<f?63&n:64,h.push(p.charAt(i)+p.charAt(s)+p.charAt(a)+p.charAt(o));return h.join("")},r.decode=function(e){var t,r,n,i,s,a,o=0,h=0,u="data:";if(e.substr(0,u.length)===u)throw new Error("Invalid base64 input, it looks like a data url.");var l,f=3*(e=e.replace(/[^A-Za-z0-9+/=]/g,"")).length/4;if(e.charAt(e.length-1)===p.charAt(64)&&f--,e.charAt(e.length-2)===p.charAt(64)&&f--,f%1!=0)throw new Error("Invalid base64 input, bad content length.");for(l=c.uint8array?new Uint8Array(0|f):new Array(0|f);o<e.length;)t=p.indexOf(e.charAt(o++))<<2|(i=p.indexOf(e.charAt(o++)))>>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=functioLine truncated
console.log('Content script loaded');
// Check if we're in a project chat by looking for the project link with forward slash
function isProjectChat() {
const projectLink = document.querySelector('a[href*="/project/"]');
if (!projectLink) {
console.log('Not in a project chat - no project link found');
return false;
}
// Additional check: look for the forward slash separator in the project breadcrumb
const breadcrumbWithSlash = projectLink.querySelector('span.opacity-50');
if (!breadcrumbWithSlash || !breadcrumbWithSlash.textContent.includes('/')) {
console.log('Not in a project chat - no forward slash separator found');
return false;
}
return true;
}
const ArtifactScraper = {
openArtifactList() {
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 (!menuButton) {
reject(new Error('Global artifact menu button not found'));
return;
}
let hasResolved = false;
// Listen for menu opening
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) {
console.log('Artifact list menu has been opened');
observer.disconnect();
if (!hasResolved) {
hasResolved = true;
resolve(true);
}
}
}
});
});
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',
code: 'Enter',
bubbles: true,
cancelable: true
});
menuButton.dispatchEvent(enterEvent);
// Fallback timeout so we don't accidentally listen forever.
setTimeout(() => {
if (!hasResolved) {
observer.disconnect();
hasResolved = true;
reject(new Error('Failed to open artifact list menu - timeout'));
}
}, 3000);
});
},
// Once we're sure the menu is open, this will give us the list of artifacts and tell
// us which one is currently selected.
getArtifactListItems() {
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;
}
menuItems.forEach((item, index) => {
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');
artifactHash[filename] = isSelected;
console.log(`Found artifact: ${filename} (${isSelected ? 'selected' : 'unselected'})`);
}
});
return artifactHash;
},
// When the menu is open, this changes the active artifact to that with the given filename.
selectArtifactListItem(filename) {
return new Promise((resolve, reject) => {
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) {
targetItem = item;
}
});
if (!targetItem) {
reject(new Error(`Could not find artifact item for: ${filename}`));
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',
code: 'Enter',
bubbles: true,
cancelable: true
});
targetItem.dispatchEvent(enterEvent);
// Wait for artifact to load
setTimeout(() => {
resolve(true);
}, 1000);
});
},
// Get artifact content and handle selection if needed
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 || '');
});
},
// Main collection method
async collectArtifacts() {
const artifactCollection = {}; // We'll keep a map of filename: content here
// 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 };
}
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 === '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 {
artifactCollection[filename] = content;
console.log(`✓ Collected: ${filename}`);
}
} catch (error) {
console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
}
// Small delay between artifacts
await new Promise(resolve => setTimeout(resolve, 500));
}
return { artifacts: artifactCollection, fileMap: fileMap };
}
};
function exportArtifacts(artifactCollection, fileMap) {
if (!artifactCollection || 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 lines = fileMap.split('\n').filter(line => line.trim());
const pathMapping = {};
let currentPath = '';
lines.forEach(line => {
const trimmedLine = line.trim();
// Check if this is a directory (ends with /)
if (trimmedLine.endsWith('/')) {
currentPath = trimmedLine;
} else if (trimmedLine && !trimmedLine.startsWith('/')) {
if (line.startsWith(' ')) {
// File is indented, so it's in the current directory
pathMapping[trimmedLine] = currentPath + trimmedLine;
} else {
// File is at root level
pathMapping[trimmedLine] = trimmedLine;
}
}
});
// Create final map with full paths and contents
const finalMap = {};
Object.entries(artifactCollection).forEach(([filename, content]) => {
const fullPath = pathMapping[filename] || filename; // Use filename as-is if not in mapping
finalMap[fullPath] = content;
});
// Create zip file
const zip = new JSZip();
// Add each file to the zip
Object
.entries(finalMap)
.forEach(([fullPath, content]) => {
// Handle directory structure
const pathParts = fullPath.split('/');
if (pathParts.length > 1) {
// File is in a directory
const filename = pathParts.pop();
const folderPath = pathParts.join('/');
const folder = zip.folder(folderPath);
folder.file(filename, content);
} else {
// File is at root level
zip.file(fullPath, content);
}
});
// Generate timestamp for filename
const now = new Date();
const timestamp = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0');
const zipFilename = `${timestamp}_claude-artifacts.zip`;
// Generate and download the zip
zip.generateAsync({type: "blob"})
.then(function(content) {
// Create download link
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = zipFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log(`✓ Downloaded: ${zipFilename}`);
})
.catch(function(error) {
console.error('Failed to generate zip:', error);
});
}
// Main function
async function main() {
console.log('Running artifact collector');
if (!isProjectChat()) {
return;
}
const result = await ArtifactScraper.collectArtifacts();
exportArtifacts(result.artifacts, result.fileMap);
}
// Run the main function
main();
})();
+172
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
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
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
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