Created
January 16, 2026 23:54
-
-
Save wrs/3453be0bc761ce5476793f72b94d964f to your computer and use it in GitHub Desktop.
Centriq export script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // Centriq (mycentriq.com) home inventory app will shut down on 1/31/26. | |
| // This script exports data in a readable format so it can be moved to a | |
| // different system. It writes a folder hierarchy containing data and photos | |
| // per room. | |
| // | |
| // Written by Claude Code -- no warranty implied (Worked for me, though.) | |
| /** | |
| * Centriq Data Export Script (Node.js) | |
| * | |
| * Exports all property data from Centriq including photos and documents. | |
| * | |
| * Usage: | |
| * node centriq-export-node.js --token "YOUR_ACCESS_TOKEN" --property "PROPERTY_ID" [--output "./export"] | |
| * | |
| * To get your access token: | |
| * 1. Log into https://app.mycentriq.com | |
| * 2. Open DevTools Console (F12) | |
| * 3. Run: JSON.parse(localStorage.getItem('centriq.local/transit/json:["~#\'","~:auth0/auth"]').match(/"~:access-token","([^"]+)"/)[1]) | |
| * | |
| * To get your property ID: | |
| * Look at the URL when viewing your property: https://app.mycentriq.com/property/PROPERTY_ID | |
| */ | |
| const https = require('https'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { URL } = require('url'); | |
| // Parse command line arguments | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| const options = { | |
| token: null, | |
| property: null, | |
| output: './centriq-export' | |
| }; | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === '--token' && args[i + 1]) { | |
| options.token = args[++i]; | |
| } else if (args[i] === '--property' && args[i + 1]) { | |
| options.property = args[++i]; | |
| } else if (args[i] === '--output' && args[i + 1]) { | |
| options.output = args[++i]; | |
| } else if (args[i] === '--help') { | |
| console.log(` | |
| Centriq Data Export Script | |
| Usage: | |
| node centriq-export-node.js --token "TOKEN" --property "PROPERTY_ID" [--output "./export"] | |
| Options: | |
| --token Your Centriq access token (required) | |
| --property Property ID from the URL (required) | |
| --output Output directory (default: ./centriq-export) | |
| --help Show this help message | |
| To get your access token, run this in browser console while logged into app.mycentriq.com: | |
| localStorage.getItem('centriq.local/transit/json:["~#\\'","~:auth0/auth"]').match(/"~:access-token","([^"]+)"/)[1] | |
| `); | |
| process.exit(0); | |
| } | |
| } | |
| if (!options.token) { | |
| console.error('Error: --token is required'); | |
| process.exit(1); | |
| } | |
| if (!options.property) { | |
| console.error('Error: --property is required'); | |
| process.exit(1); | |
| } | |
| return options; | |
| } | |
| // GraphQL request helper | |
| async function graphql(accessToken, operationName, query, variables = {}) { | |
| const postData = JSON.stringify({ operationName, query, variables }); | |
| return new Promise((resolve, reject) => { | |
| const req = https.request({ | |
| hostname: 'api-production.centriqhome.com', | |
| path: '/graphql/2022-06-06', | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Content-Length': Buffer.byteLength(postData), | |
| 'Authorization': `Bearer ${accessToken}`, | |
| 'x-centriq-client-name': 'web', | |
| 'x-centriq-client-app': 'centriq' | |
| } | |
| }, (res) => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => { | |
| try { | |
| resolve(JSON.parse(data)); | |
| } catch (e) { | |
| reject(new Error(`Failed to parse response: ${data.substring(0, 200)}`)); | |
| } | |
| }); | |
| }); | |
| req.on('error', reject); | |
| req.write(postData); | |
| req.end(); | |
| }); | |
| } | |
| // Download file helper | |
| async function downloadFile(url, destPath) { | |
| return new Promise((resolve, reject) => { | |
| const parsedUrl = new URL(url); | |
| const req = https.get({ | |
| hostname: parsedUrl.hostname, | |
| path: parsedUrl.pathname + parsedUrl.search, | |
| headers: { | |
| 'User-Agent': 'Centriq-Export/1.0' | |
| } | |
| }, (res) => { | |
| if (res.statusCode === 301 || res.statusCode === 302) { | |
| // Follow redirect | |
| downloadFile(res.headers.location, destPath).then(resolve).catch(reject); | |
| return; | |
| } | |
| if (res.statusCode !== 200) { | |
| reject(new Error(`HTTP ${res.statusCode}`)); | |
| return; | |
| } | |
| const dir = path.dirname(destPath); | |
| if (!fs.existsSync(dir)) { | |
| fs.mkdirSync(dir, { recursive: true }); | |
| } | |
| const fileStream = fs.createWriteStream(destPath); | |
| res.pipe(fileStream); | |
| fileStream.on('finish', () => { | |
| fileStream.close(); | |
| resolve(destPath); | |
| }); | |
| fileStream.on('error', (err) => { | |
| fs.unlink(destPath, () => {}); | |
| reject(err); | |
| }); | |
| }); | |
| req.on('error', reject); | |
| }); | |
| } | |
| // Sanitize filename | |
| function sanitize(str, maxLength = 80) { | |
| return (str || 'unnamed') | |
| .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .substring(0, maxLength); | |
| } | |
| // Get file extension from mediaType or URL | |
| function getExtension(mediaType, url) { | |
| const typeMap = { | |
| 'image/jpeg': 'jpg', | |
| 'image/png': 'png', | |
| 'image/gif': 'gif', | |
| 'image/webp': 'webp', | |
| 'image/heic': 'heic', | |
| 'application/pdf': 'pdf', | |
| 'video/mp4': 'mp4', | |
| 'video/quicktime': 'mov', | |
| 'text/plain': 'txt' | |
| }; | |
| if (typeMap[mediaType]) return typeMap[mediaType]; | |
| // Try to extract from URL | |
| const urlMatch = url?.match(/\.([a-z0-9]+)\?/i); | |
| if (urlMatch) return urlMatch[1].toLowerCase(); | |
| // Try from URL path | |
| const pathMatch = url?.match(/\.([a-z0-9]+)$/i); | |
| if (pathMatch) return pathMatch[1].toLowerCase(); | |
| return 'bin'; | |
| } | |
| // Main export function | |
| async function exportCentriq(options) { | |
| const { token, property: propertyId, output } = options; | |
| console.log('🏠 Centriq Data Export\n'); | |
| // Create output directory | |
| if (!fs.existsSync(output)) { | |
| fs.mkdirSync(output, { recursive: true }); | |
| } | |
| // Fetch property details | |
| console.log('📋 Fetching property details...'); | |
| const propertyResult = await graphql(token, 'property', ` | |
| query property($id: UUID!) { | |
| property: lookupEntity(typeName: "Property", uuid: $id) { | |
| ... on Property { | |
| id: uuid | |
| nickname | |
| address { street1 street2 locality region postalCode country } | |
| assetGroups { id: uuid name } | |
| contents { | |
| id: uuid | |
| title | |
| description | |
| link | |
| category | |
| mediaType | |
| curated | |
| createdAt | |
| } | |
| } | |
| } | |
| } | |
| `, { id: propertyId }); | |
| if (!propertyResult.data?.property) { | |
| console.error('❌ Failed to fetch property. Check your token and property ID.'); | |
| console.error('Response:', JSON.stringify(propertyResult, null, 2)); | |
| process.exit(1); | |
| } | |
| const property = propertyResult.data.property; | |
| const propName = sanitize(property.nickname || 'Property'); | |
| const propDir = path.join(output, propName); | |
| console.log(`\n🏡 Property: ${property.nickname}`); | |
| console.log(` Location: ${property.address?.locality}, ${property.address?.region} ${property.address?.postalCode}`); | |
| console.log(` Groups: ${property.assetGroups.length}`); | |
| // Export data structure | |
| const exportData = { | |
| exportDate: new Date().toISOString(), | |
| property: { | |
| id: property.id, | |
| nickname: property.nickname, | |
| address: property.address | |
| }, | |
| groups: [] | |
| }; | |
| // Download property-level documents | |
| const propDocs = (property.contents || []).filter(c => !c.curated); | |
| if (propDocs.length > 0) { | |
| console.log(`\n📄 Downloading ${propDocs.length} property documents...`); | |
| const propDocsDir = path.join(propDir, '_property_documents'); | |
| for (const doc of propDocs) { | |
| if (doc.link && !doc.link.startsWith('data:')) { | |
| const ext = getExtension(doc.mediaType, doc.link); | |
| const filename = sanitize(doc.title || doc.category || doc.id) + '.' + ext; | |
| const destPath = path.join(propDocsDir, filename); | |
| try { | |
| await downloadFile(doc.link, destPath); | |
| console.log(` ✓ ${filename}`); | |
| } catch (e) { | |
| console.log(` ✗ ${filename} (${e.message})`); | |
| } | |
| } | |
| } | |
| } | |
| // Process each asset group | |
| let totalAssets = 0; | |
| let totalFiles = 0; | |
| for (let gi = 0; gi < property.assetGroups.length; gi++) { | |
| const group = property.assetGroups[gi]; | |
| const groupName = sanitize(group.name); | |
| const groupDir = path.join(propDir, groupName); | |
| console.log(`\n📁 [${gi + 1}/${property.assetGroups.length}] ${group.name}`); | |
| // Fetch group details with assets | |
| const groupResult = await graphql(token, 'assetGroup', ` | |
| query assetGroup($id: UUID!) { | |
| entity: lookupEntity(uuid: $id, typeName: "AssetGroup") { | |
| ... on AssetGroup { | |
| id: uuid | |
| name | |
| createdAt | |
| contents { | |
| id: uuid | |
| title | |
| description | |
| link | |
| category | |
| mediaType | |
| curated | |
| createdAt | |
| } | |
| assets { | |
| id: uuid | |
| nickname | |
| tagName | |
| manufacturerName | |
| modelNumber | |
| serialNumber | |
| createdAt | |
| manufactureDate { year month day } | |
| purchase { | |
| date { year month day } | |
| price | |
| salesTax | |
| shippingFee | |
| notes | |
| store { name location } | |
| } | |
| installation { date { year month day } } | |
| manufacturerWarranty { | |
| duration { unit value } | |
| expirationDate { year month day } | |
| notes | |
| } | |
| extendedWarranty { | |
| duration { unit value } | |
| expirationDate { year month day } | |
| provider | |
| premium | |
| deductible | |
| notes | |
| } | |
| primaryImage { link: imageLink(dimensions: {width: 1200, height: 1200}) } | |
| } | |
| } | |
| } | |
| } | |
| `, { id: group.id }); | |
| if (!groupResult.data?.entity) { | |
| console.log(' ⚠ Failed to fetch group details'); | |
| continue; | |
| } | |
| const groupEntity = groupResult.data.entity; | |
| const assets = groupEntity.assets || []; | |
| console.log(` Assets: ${assets.length}`); | |
| totalAssets += assets.length; | |
| const groupData = { | |
| id: group.id, | |
| name: groupEntity.name, | |
| createdAt: groupEntity.createdAt, | |
| assets: [] | |
| }; | |
| // Download group-level documents | |
| const groupDocs = (groupEntity.contents || []).filter(c => !c.curated); | |
| if (groupDocs.length > 0) { | |
| const groupDocsDir = path.join(groupDir, '_group_documents'); | |
| for (const doc of groupDocs) { | |
| if (doc.link && !doc.link.startsWith('data:')) { | |
| const ext = getExtension(doc.mediaType, doc.link); | |
| const filename = sanitize(doc.title || doc.category || doc.id) + '.' + ext; | |
| const destPath = path.join(groupDocsDir, filename); | |
| try { | |
| await downloadFile(doc.link, destPath); | |
| totalFiles++; | |
| } catch (e) { | |
| // Silently continue | |
| } | |
| } | |
| } | |
| } | |
| // Process each asset | |
| for (let ai = 0; ai < assets.length; ai++) { | |
| const asset = assets[ai]; | |
| // Create readable asset folder name | |
| const assetParts = [ | |
| asset.tagName || 'Item', | |
| asset.manufacturerName, | |
| asset.modelNumber | |
| ].filter(Boolean); | |
| const assetFolderName = sanitize(assetParts.join(' - ')); | |
| const assetDir = path.join(groupDir, assetFolderName); | |
| // Fetch asset contents (both user-uploaded and curated/manufacturer-provided) | |
| const assetContents = await graphql(token, 'assetContents', ` | |
| query assetContents($id: UUID!) { | |
| entity: lookupEntity(typeName: "Asset", uuid: $id) { | |
| ... on Asset { | |
| contents { | |
| id: uuid | |
| title | |
| description | |
| link | |
| category | |
| mediaType | |
| curated | |
| createdAt | |
| } | |
| contentsCurated: contents(recursive: true, curated: true) { | |
| id: uuid | |
| title | |
| description | |
| link | |
| category | |
| mediaType | |
| curated | |
| createdAt | |
| } | |
| } | |
| } | |
| } | |
| `, { id: asset.id }); | |
| // User-uploaded content | |
| const userContents = (assetContents.data?.entity?.contents || []).filter(c => !c.curated); | |
| // Curated content (manuals, guides from manufacturer) | |
| const curatedContents = (assetContents.data?.entity?.contentsCurated || []).filter(c => | |
| // Only include downloadable files (PDFs, images), not YouTube links etc. | |
| c.link && | |
| !c.link.startsWith('data:') && | |
| !c.link.includes('youtube.com') && | |
| (c.mediaType?.startsWith('application/') || c.mediaType?.startsWith('image/')) | |
| ); | |
| // Build asset data | |
| const assetData = { | |
| id: asset.id, | |
| nickname: asset.nickname, | |
| type: asset.tagName, | |
| manufacturer: asset.manufacturerName, | |
| model: asset.modelNumber, | |
| serial: asset.serialNumber, | |
| createdAt: asset.createdAt, | |
| manufactureDate: asset.manufactureDate, | |
| purchase: asset.purchase, | |
| installation: asset.installation, | |
| manufacturerWarranty: asset.manufacturerWarranty, | |
| extendedWarranty: asset.extendedWarranty, | |
| files: [] | |
| }; | |
| // Download primary image | |
| if (asset.primaryImage?.link) { | |
| const ext = getExtension('image/jpeg', asset.primaryImage.link); | |
| const filename = `photo.${ext}`; | |
| const destPath = path.join(assetDir, filename); | |
| try { | |
| await downloadFile(asset.primaryImage.link, destPath); | |
| assetData.files.push({ type: 'primary_image', filename }); | |
| totalFiles++; | |
| } catch (e) { | |
| // Continue | |
| } | |
| } | |
| // Download user contents | |
| const categoryCount = {}; | |
| for (const content of userContents) { | |
| if (content.link && !content.link.startsWith('data:')) { | |
| const ext = getExtension(content.mediaType, content.link); | |
| const category = content.category || 'file'; | |
| // Generate unique filename | |
| categoryCount[category] = (categoryCount[category] || 0) + 1; | |
| let filename; | |
| if (content.title) { | |
| filename = sanitize(content.title) + '.' + ext; | |
| } else if (categoryCount[category] === 1) { | |
| filename = `${category}.${ext}`; | |
| } else { | |
| filename = `${category}_${categoryCount[category]}.${ext}`; | |
| } | |
| const destPath = path.join(assetDir, filename); | |
| try { | |
| await downloadFile(content.link, destPath); | |
| assetData.files.push({ | |
| type: content.category, | |
| filename, | |
| title: content.title, | |
| description: content.description | |
| }); | |
| totalFiles++; | |
| } catch (e) { | |
| // Continue | |
| } | |
| } | |
| } | |
| // Download curated contents (manuals, guides from manufacturer) | |
| if (curatedContents.length > 0) { | |
| const manualsDir = path.join(assetDir, 'manuals'); | |
| const curatedCategoryCount = {}; | |
| for (const content of curatedContents) { | |
| const ext = getExtension(content.mediaType, content.link); | |
| const category = content.category || 'manual'; | |
| // Generate unique filename | |
| curatedCategoryCount[category] = (curatedCategoryCount[category] || 0) + 1; | |
| let filename; | |
| if (content.title) { | |
| filename = sanitize(content.title) + '.' + ext; | |
| } else if (curatedCategoryCount[category] === 1) { | |
| filename = `${category}.${ext}`; | |
| } else { | |
| filename = `${category}_${curatedCategoryCount[category]}.${ext}`; | |
| } | |
| const destPath = path.join(manualsDir, filename); | |
| try { | |
| await downloadFile(content.link, destPath); | |
| assetData.files.push({ | |
| type: 'manual', | |
| filename: `manuals/${filename}`, | |
| title: content.title, | |
| description: content.description, | |
| curated: true | |
| }); | |
| totalFiles++; | |
| } catch (e) { | |
| // Continue | |
| } | |
| } | |
| } | |
| // Save asset metadata as JSON | |
| if (!fs.existsSync(assetDir)) { | |
| fs.mkdirSync(assetDir, { recursive: true }); | |
| } | |
| fs.writeFileSync( | |
| path.join(assetDir, '_metadata.json'), | |
| JSON.stringify(assetData, null, 2) | |
| ); | |
| groupData.assets.push(assetData); | |
| // Progress indicator | |
| process.stdout.write(` Processing assets: ${ai + 1}/${assets.length}\r`); | |
| } | |
| console.log(` ✓ Processed ${assets.length} assets`); | |
| exportData.groups.push(groupData); | |
| } | |
| // Save main export JSON | |
| fs.writeFileSync( | |
| path.join(propDir, '_export.json'), | |
| JSON.stringify(exportData, null, 2) | |
| ); | |
| console.log('\n' + '='.repeat(50)); | |
| console.log('✅ Export complete!'); | |
| console.log(` Output directory: ${path.resolve(propDir)}`); | |
| console.log(` Total groups: ${exportData.groups.length}`); | |
| console.log(` Total assets: ${totalAssets}`); | |
| console.log(` Total files downloaded: ${totalFiles}`); | |
| console.log('\nFolder structure:'); | |
| console.log(` ${propName}/`); | |
| console.log(` ├── _export.json (complete metadata)`); | |
| console.log(` ├── _property_documents/`); | |
| console.log(` ├── <Group Name>/`); | |
| console.log(` │ ├── _group_documents/`); | |
| console.log(` │ └── <Item Type - Brand - Model>/`); | |
| console.log(` │ ├── _metadata.json`); | |
| console.log(` │ ├── photo.jpg`); | |
| console.log(` │ ├── nameplate.jpg`); | |
| console.log(` │ └── receipt.pdf`); | |
| } | |
| // Run | |
| const options = parseArgs(); | |
| exportCentriq(options).catch(err => { | |
| console.error('❌ Export failed:', err.message); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment