Skip to content

Instantly share code, notes, and snippets.

@wrs
Created January 16, 2026 23:54
Show Gist options
  • Select an option

  • Save wrs/3453be0bc761ce5476793f72b94d964f to your computer and use it in GitHub Desktop.

Select an option

Save wrs/3453be0bc761ce5476793f72b94d964f to your computer and use it in GitHub Desktop.
Centriq export script
#!/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