// ==UserScript==
// @name MyHeritage Family Tree Exporter
// @version 1.1.0
// @description Exports the complete MyHeritage family tree (people, families, lineage codes, photos) into a ZIP with a JSON file and a MyHeritage_attachments folder.
// @match https://www.myheritage.com/family-trees/*
// @match https://www.myheritage.cz/family-trees/*
// @include /^https:\/\/www\.myheritage\.[a-z.]+\/family-trees\//
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
* HOW IT WORKS (findings from reverse-engineering the "family view" page)
* ----------------------------------------------------------------------
* The family-view page keeps its data model in `window.newTree`:
* newTree.genealogy._individualsCollection._allIndividuals -> {id: Individual}
* newTree.genealogy._familiesCollection._allFamilies -> {id: Family}
* Only a pruned neighbourhood (~3 generations) around the *pivot* person is loaded.
* The "View family branch" button on a card runs
* newTree.dataAcquisition.setRootIndividualID(id);
* newTree.dataAcquisition.requestTreeData(false);
* which reloads the model around a new pivot WITHOUT a page reload.
* The exporter drives that call over every person until the whole tree
* (newTree.genealogy.getNumberOfIndividualsInTree()) has been collected.
*
* Photos: card data only carries a 96 px signed thumbnail. The full-size
* profile photo comes from
* POST /FP/API/Profile/get-profile-photo.php body: individualID=<id>&s=<siteID>
* which returns XML with <profilePhotoFullUrl>. Those URLs are signed and expire,
* so images are downloaded at export time into MyHeritage_attachments/<id>.<ext>.
*
* Lineage code (same convention MyHeritage uses for its colour coding):
* "" = home person, "f"/"m" = father/mother, "ff","fm","mf","mm" = grandparents,
* "c" = descendant of the home person, "" also for spouses/in-laws/siblings.
* Colours are NOT written into the JSON; the post-processing tool maps lineage -> colour.
* MyHeritage's own palette is included as `referencePalette` for convenience.
*
* Output: one ZIP (Downloads folder) containing
* <name>.json
* MyHeritage_attachments/<individualId>.jpg
* The ZIP is written by a small built-in "store" writer (no compression, no external
* library) so the script has no @require and is not affected by the site's CSP.
*/
(function () {
'use strict';
const LOG_PREFIX = '[MH-Export]';
const ATTACH_DIR = 'MyHeritage_attachments';
const JUMP_TIMEOUT_MS = 15000; // a normal pivot loads in ~1 s; longer = the server refused it
// The site rate-limits tree requests (HTTP 429, shown as "An error occurred. Please try again later.").
// The pause between pivots is adaptive: it doubles after every refusal and slowly relaxes after successes.
const JUMP_DELAY_MIN_MS = 2000, JUMP_DELAY_MAX_MS = 20000;
const RETRY_WAITS_MS = [30000, 60000, 120000, 240000]; // extra back-off before retrying a refused pivot
const SETTLE_MS = 350;
// ------------------------------------------------------------------ utils
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.log(LOG_PREFIX, ...a);
async function waitFor(cond, timeoutMs, stepMs = 100) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
try { if (cond()) return true; } catch (e) { /* not ready */ }
await sleep(stepMs);
}
return false;
}
function str(v) { return v === undefined || v === null ? '' : String(v); }
function numOrNull(v) { const n = parseInt(v, 10); return isNaN(n) ? null : n; }
function safeFileName(s) { return str(s).replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim(); }
function timestamp() {
const d = new Date(), p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
// ------------------------------------------------------------ zip writer
// Minimal ZIP (method 0 = store). JPEGs do not compress anyway; JSON is small.
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
class SimpleZip {
constructor() { this.entries = []; }
file(name, data) { this.entries.push({ name, data }); }
async generateAsync() {
const enc = new TextEncoder();
const now = new Date();
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
const parts = [], central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of this.entries) {
const bytes = typeof e.data === 'string' ? enc.encode(e.data) : new Uint8Array(await e.data.arrayBuffer());
const nameBytes = enc.encode(e.name);
const crc = crc32(bytes);
const common = [...u16(20), ...u16(0x0800), ...u16(0), ...u16(dosTime), ...u16(dosDate), ...u32(crc), ...u32(bytes.length), ...u32(bytes.length), ...u16(nameBytes.length)];
const local = new Uint8Array([...u32(0x04034B50), ...common, ...u16(0), ...nameBytes]);
parts.push(local, bytes);
central.push(new Uint8Array([...u32(0x02014B50), ...u16(20), ...common, ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(offset), ...nameBytes]));
offset += local.length + bytes.length;
}
const centralSize = central.reduce((s, c) => s + c.length, 0);
const eocd = new Uint8Array([...u32(0x06054B50), ...u16(0), ...u16(0), ...u16(this.entries.length), ...u16(this.entries.length), ...u32(centralSize), ...u32(offset), ...u16(0)]);
return new Blob([...parts, ...central, eocd], { type: 'application/zip' });
}
}
function treeReady() {
const nt = window.newTree;
return !!(nt && nt.genealogy && nt.dataAcquisition && nt.canvas &&
nt.genealogy._individualsCollection && nt.genealogy._individualsCollection.getCount() > 0);
}
// ------------------------------------------------------------- collector
class TreeCollector {
constructor() {
this.individuals = new Map(); // id -> record
this.families = new Map(); // id -> record
this.pivoted = new Set();
this.pruned = new Set(); // ids whose branches were cut in some view
this.complete = new Set(); // ids seen as a NON-pruned card: all their immediate relatives were in that view
this.failed = new Set(); // pivots the server kept refusing
this.siteId = str(window.newTree.genealogy.getSiteID());
this.treeId = str(window.newTree.genealogy.getTreeID());
this.homeId = str(window.newTree.dataAcquisition.getHomeIndividualID());
this.treeSize = numOrNull(window.newTree.genealogy.getNumberOfIndividualsInTree()) || 0;
}
// real ids are positive integers; the page uses "0" for a missing spouse and "addme_N" for placeholders
static isRealId(id) { return /^\d+$/.test(str(id)) && parseInt(id, 10) > 0; }
mergeCurrentView() {
const g = window.newTree.genealogy;
const inds = Object.values(g._individualsCollection._allIndividuals || {}).filter(Boolean);
for (const i of inds) {
const id = str(i._ID);
if (!TreeCollector.isRealId(id)) continue;
// "addme_N" ids are the page's "add parents / add partner" placeholders, not real families
const parentFamilies = (i._familiesIAmAChildIn || [])
.map((f) => typeof f === 'object' ? { id: str(f.id), relation: str(f.relation) } : { id: str(f), relation: '' })
.filter((f) => TreeCollector.isRealId(f.id));
const spouseFamilies = (i._familiesIAmAParentIn || [])
.map((f) => str(typeof f === 'object' ? f.id : f))
.filter(TreeCollector.isRealId);
let profileUrl = '';
try { profileUrl = new URL(i.getProfilePageURL(), location.origin).href; } catch (e) { /* ignore */ }
const prev = this.individuals.get(id);
const rec = {
id,
name: str(i._displayName),
firstName: str(i._firstName),
lastName: str(i._lastName),
gender: str(i._gender), // "M" / "F" / ""
birthDate: str(i._birthDate), // localized text as shown on the card
birthYear: numOrNull(i._birthYear),
deathDate: str(i._deathDate),
deathYear: numOrNull(i._deathYear),
isAlive: !!i._isAlive,
parentFamilies,
spouseFamilies,
thumbUrl: str(i._smallThumbURL),
hasPhoto: !!i._smallThumbURL,
profileUrl,
lineage: '',
generation: null,
photo: null
};
if (prev) {
// union of family links (a pruned view may hide some of them)
const pf = new Map(prev.parentFamilies.map((x) => [x.id, x]));
for (const x of parentFamilies) pf.set(x.id, x);
rec.parentFamilies = [...pf.values()];
rec.spouseFamilies = [...new Set([...prev.spouseFamilies, ...spouseFamilies])];
if (!rec.thumbUrl && prev.thumbUrl) { rec.thumbUrl = prev.thumbUrl; rec.hasPhoto = true; }
}
this.individuals.set(id, rec);
}
const fams = Object.values(g._familiesCollection._allFamilies || {}).filter(Boolean);
for (const f of fams) {
const id = str(f._ID);
if (!TreeCollector.isRealId(id)) continue; // skip "addme_N" placeholders
const prev = this.families.get(id);
const children = (f._childrenIDs || []).map(str).filter(TreeCollector.isRealId);
const rec = {
id,
husband: TreeCollector.isRealId(f._husbandID) ? str(f._husbandID) : '',
wife: TreeCollector.isRealId(f._wifeID) ? str(f._wifeID) : '',
status: str(f._status),
children
};
if (prev) {
rec.husband = rec.husband || prev.husband;
rec.wife = rec.wife || prev.wife;
rec.status = rec.status || prev.status;
rec.children = [...new Set([...prev.children, ...children])];
}
this.families.set(id, rec);
}
// cards whose branch is cut in this view (ridge icon) vs. cards shown with all their relatives
try {
const cards = Object.values(window.newTree.canvas._personCardsCollection._allPersonCards || {}).filter(Boolean);
for (const c of cards) {
if (typeof c.getIsPruned !== 'function' || !c.getTargetPerson) continue;
const p = c.getTargetPerson();
if (!p || !TreeCollector.isRealId(p._ID)) continue;
if (c.getIsPruned()) this.pruned.add(str(p._ID)); else this.complete.add(str(p._ID));
}
} catch (e) { log('pruned-card scan failed', e); }
const root = str(g.getRootIndividualID());
if (TreeCollector.isRealId(root)) this.complete.add(root);
}
remainingToCheck() {
let n = 0;
for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) n++;
return n;
}
nextPivot(thorough) {
// 1) cut branches first (that is where unknown people hide)
for (const id of this.pruned) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
if (!thorough && this.treeSize && this.individuals.size >= this.treeSize) return null;
// 2) then only people never shown with their full neighbourhood - NOT everyone (each pivot is a server request)
for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
return null;
}
// The page calls dataAcquisition.treeLayoutLoaded(...) when a pivot's data has been
// applied to the model. Hooking it gives an event-driven completion signal, which also
// works in a background tab where setTimeout polling would be throttled to ~1/min.
static installLoadedHook() {
const da = window.newTree.dataAcquisition;
if (da.__mhHooked) return;
const orig = da.treeLayoutLoaded;
da.__mhWaiters = [];
const fire = (ok) => { const w = da.__mhWaiters; da.__mhWaiters = []; w.forEach((f) => { try { f(ok); } catch (e) { /* ignore */ } }); };
da.treeLayoutLoaded = function () {
const r = orig.apply(this, arguments);
fire(true);
return r;
};
// a refused layout request (HTTP 429 with an empty body when the rate limit hits, or any other
// HTTP error) should fail fast instead of waiting for the timeout
try {
const oOpen = XMLHttpRequest.prototype.open, oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, u) { this.__mhUrl = String(u || ''); return oOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function () {
if (/get-tree-layout/.test(this.__mhUrl)) {
this.addEventListener('loadend', () => { if (this.status >= 400 || this.status === 0) fire(false); });
}
return oSend.apply(this, arguments);
};
} catch (e) { log('xhr hook failed', e); }
da.__mhHooked = true;
}
async jumpTo(id) {
const da = window.newTree.dataAcquisition, g = window.newTree.genealogy;
TreeCollector.installLoadedHook();
try { da.setOpenSpiderAfterPruneJump(false); } catch (e) { /* ignore */ }
const loaded = new Promise((res) => {
da.__mhWaiters.push((ok) => res(ok));
setTimeout(() => res(false), JUMP_TIMEOUT_MS);
});
da.setRootIndividualID(id);
da.requestTreeData(false);
// success = treeLayoutLoaded fired. There is deliberately NO fallback on the root id: the page
// sets genealogy's root id BEFORE the request, so after a 429 it already "matches" while the
// model still holds the previous pivot's people.
const ok = await loaded;
await sleep(SETTLE_MS); // let the canvas rebuild its card collection
return ok;
}
// ------------------------------------------------ derived fields
computeLineageAndGeneration() {
const byId = this.individuals;
const fam = this.families;
const parentsOf = (id) => {
const out = [];
const p = byId.get(id);
if (!p) return out;
for (const pf of p.parentFamilies) {
const f = fam.get(pf.id);
if (!f) continue;
if (f.husband) out.push({ id: f.husband, code: 'f' });
if (f.wife) out.push({ id: f.wife, code: 'm' });
}
return out;
};
const childrenOf = (id) => {
const out = [];
const p = byId.get(id);
if (!p) return out;
for (const fid of p.spouseFamilies) {
const f = fam.get(fid);
if (f) out.push(...f.children);
}
return out;
};
const spousesOf = (id) => {
const out = [];
const p = byId.get(id);
if (!p) return out;
for (const fid of p.spouseFamilies) {
const f = fam.get(fid);
if (!f) continue;
if (f.husband && f.husband !== id) out.push(f.husband);
if (f.wife && f.wife !== id) out.push(f.wife);
}
return out;
};
// lineage: ancestors of home get f/m path, descendants get "c"
const home = this.homeId;
if (byId.has(home)) {
byId.get(home).lineage = '';
const q = [{ id: home, code: '' }];
const seen = new Set([home]);
while (q.length) {
const { id, code } = q.shift();
for (const par of parentsOf(id)) {
if (seen.has(par.id) || !byId.has(par.id)) continue;
seen.add(par.id);
const c = code + par.code;
byId.get(par.id).lineage = c;
q.push({ id: par.id, code: c });
}
}
const dq = [home];
const dseen = new Set([home]);
while (dq.length) {
const id = dq.shift();
for (const ch of childrenOf(id)) {
if (dseen.has(ch) || !byId.has(ch)) continue;
dseen.add(ch);
byId.get(ch).lineage = 'c';
dq.push(ch);
}
}
}
// generation relative to home (ancestors positive, descendants negative, spouses equal)
if (byId.has(home)) {
byId.get(home).generation = 0;
const q = [home];
while (q.length) {
const id = q.shift();
const gen = byId.get(id).generation;
const visit = (nid, ngen) => {
const n = byId.get(nid);
if (!n || n.generation !== null) return;
n.generation = ngen;
q.push(nid);
};
for (const par of parentsOf(id)) visit(par.id, gen + 1);
for (const ch of childrenOf(id)) visit(ch, gen - 1);
for (const sp of spousesOf(id)) visit(sp, gen);
}
}
}
// ------------------------------------------------------ photos
async fetchFullPhotoUrl(id) {
try {
const r = await fetch('/FP/API/Profile/get-profile-photo.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'individualID=' + encodeURIComponent(id) + '&s=' + encodeURIComponent(this.siteId)
});
const xml = new DOMParser().parseFromString(await r.text(), 'text/xml');
const full = xml.querySelector('profilePhotoFullUrl');
const norm = xml.querySelector('profilePhotoUrl');
return (full && full.textContent) || (norm && norm.textContent) || '';
} catch (e) {
log('get-profile-photo failed for', id, e);
return '';
}
}
async downloadPhoto(rec, zip, useFullSize) {
let url = useFullSize ? await this.fetchFullPhotoUrl(rec.id) : '';
let source = url ? 'profile-full' : '';
if (!url) { url = rec.thumbUrl; source = 'card-thumbnail'; }
if (!url) return null;
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const blob = await r.blob();
const ext = blob.type === 'image/png' ? 'png' : blob.type === 'image/gif' ? 'gif' : blob.type === 'image/webp' ? 'webp' : 'jpg';
let width = null, height = null;
try { const bm = await createImageBitmap(blob); width = bm.width; height = bm.height; if (bm.close) bm.close(); } catch (e) { /* ignore */ }
const file = ATTACH_DIR + '/' + rec.id + '.' + ext;
if (zip) zip.file(file, blob);
return { file, width, height, bytes: blob.size, source, sourceUrl: url };
}
// ------------------------------------------------------ output
toJson() {
const palette = (window.treeColorCodingService && window.treeColorCodingService._colors) || null;
return {
format: 'myheritage-tree-export/1',
exportedAt: new Date().toISOString(),
source: {
url: location.href.split('?')[0],
siteId: this.siteId,
treeId: this.treeId,
title: str(window.familyTreeTitle || document.title),
treeSizeReported: this.treeSize
},
homeIndividualId: this.homeId,
// note: no empty-string keys anywhere in this file - PowerShell 5.1's ConvertFrom-Json rejects them
lineageLegend: {
'none': 'empty string = home person, spouses, siblings and in-laws (no lineage)',
'f/m...': 'ancestor path from home person: f = father, m = mother, e.g. "fm" = father\'s mother',
'c': 'descendant of the home person'
},
referencePalette: palette, // MyHeritage colour coding: lineage -> [fill, border]
attachmentsDir: ATTACH_DIR,
individuals: [...this.individuals.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })),
families: [...this.families.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
};
}
}
// ----------------------------------------------------------------- UI
function buildUi() {
const css = `
#mhx_btn{position:fixed;right:18px;bottom:18px;z-index:99999;background:#d14900;color:#fff;border:0;border-radius:24px;
padding:10px 18px;font:600 14px/1 Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.35)}
#mhx_btn:hover{background:#b33f00}
#mhx_panel{position:fixed;right:18px;bottom:64px;z-index:99999;width:360px;background:#fff;border:1px solid #ccc;border-radius:8px;
box-shadow:0 4px 16px rgba(0,0,0,.3);font:13px/1.4 Arial,sans-serif;color:#222;display:none}
#mhx_panel h3{margin:0;padding:10px 14px;background:#f4f4f4;border-bottom:1px solid #ddd;font-size:14px}
#mhx_panel .body{padding:10px 14px}
#mhx_panel label{display:block;margin:4px 0}
#mhx_panel .row{display:flex;gap:8px;margin-top:8px}
#mhx_panel button{background:#d14900;color:#fff;border:0;border-radius:16px;padding:6px 14px;cursor:pointer}
#mhx_panel button.sec{background:#888}
#mhx_panel button:disabled{background:#bbb;cursor:default}
#mhx_log{margin-top:8px;height:140px;overflow:auto;background:#fafafa;border:1px solid #e5e5e5;padding:6px;font:12px/1.3 Consolas,monospace;white-space:pre-wrap}
#mhx_bar{height:6px;background:#eee;border-radius:3px;margin-top:8px;overflow:hidden}
#mhx_bar i{display:block;height:100%;width:0;background:#2d7559;transition:width .2s}`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
const btn = document.createElement('button');
btn.id = 'mhx_btn';
btn.textContent = 'Export tree';
document.body.appendChild(btn);
const panel = document.createElement('div');
panel.id = 'mhx_panel';
panel.innerHTML = `
<h3>MyHeritage tree export</h3>
<div class="body">
<label><input type="checkbox" id="mhx_photos" checked> Download photos (full size) into ${ATTACH_DIR}/</label>
<label><input type="checkbox" id="mhx_thorough" checked> Thorough crawl (also visit people whose branches were always cut off)</label>
<div class="row">
<button id="mhx_run">Start export</button>
<button id="mhx_close" class="sec">Close</button>
</div>
<div id="mhx_bar"><i></i></div>
<div id="mhx_log"></div>
</div>`;
document.body.appendChild(panel);
const logBox = panel.querySelector('#mhx_log');
const bar = panel.querySelector('#mhx_bar i');
let lastWasStatus = false;
const ui = {
log(msg) { logBox.textContent += msg + '\n'; logBox.scrollTop = logBox.scrollHeight; lastWasStatus = false; log(msg); },
// status() rewrites the previous status line instead of adding a new one
status(msg) {
if (lastWasStatus) {
const lines = logBox.textContent.split('\n');
lines.splice(-2, 1); // drop the previous status line (last element is '' after the trailing \n)
logBox.textContent = lines.join('\n');
}
logBox.textContent += msg + '\n';
logBox.scrollTop = logBox.scrollHeight;
lastWasStatus = true;
},
progress(p) { bar.style.width = Math.max(0, Math.min(100, p)) + '%'; },
options() { return { photos: panel.querySelector('#mhx_photos').checked, thorough: panel.querySelector('#mhx_thorough').checked }; },
busy(b) { panel.querySelector('#mhx_run').disabled = b; }
};
btn.addEventListener('click', () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; });
panel.querySelector('#mhx_close').addEventListener('click', () => { panel.style.display = 'none'; });
panel.querySelector('#mhx_run').addEventListener('click', async () => {
ui.busy(true);
try { await runExport(ui.options(), ui); }
catch (e) { ui.log('ERROR: ' + (e && e.message ? e.message : e)); console.error(e); }
finally { ui.busy(false); }
});
return ui;
}
// --------------------------------------------------------------- main
async function runExport(opts, ui, testOnly) {
if (!treeReady()) throw new Error('Tree model not ready (newTree missing). Open the family view first.');
const col = new TreeCollector();
ui.log(`Site ${col.siteId}, tree ${col.treeId}, home person ${col.homeId}, ${col.treeSize} people reported.`);
// 1) crawl
// phase 1: discover people (one log line per pivot while the count still grows)
// phase 2: every remaining person is visited once so that no family link is missed;
// the count no longer changes, so this phase is one status line updated in place
col.mergeCurrentView();
col.pivoted.add(str(window.newTree.genealogy.getRootIndividualID()));
let jumps = 0, phase = 1;
const allFound = () => col.treeSize > 0 && col.individuals.size >= col.treeSize;
let phase2Total = 0, delay = JUMP_DELAY_MIN_MS, refused = 0;
for (;;) {
const next = col.nextPivot(opts.thorough);
if (!next) break;
let ok = false;
for (let attempt = 0; attempt <= RETRY_WAITS_MS.length; attempt++) {
ok = await col.jumpTo(next);
jumps++;
if (ok) { delay = Math.max(JUMP_DELAY_MIN_MS, Math.round(delay * 0.9)); break; }
refused++;
delay = Math.min(JUMP_DELAY_MAX_MS, delay * 2);
if (attempt < RETRY_WAITS_MS.length) {
const w = RETRY_WAITS_MS[attempt];
ui.log(` server refused pivot ${next} (rate limit) - pausing ${w / 1000} s, retry ${attempt + 1}/${RETRY_WAITS_MS.length}; spacing now ${delay / 1000} s`);
await sleep(w);
}
}
col.pivoted.add(next);
if (!ok) { col.failed.add(next); ui.log(` warning: pivot ${next} skipped after ${RETRY_WAITS_MS.length} retries`); }
else col.mergeCurrentView();
if (phase === 1) {
ui.progress(col.treeSize ? (col.individuals.size / col.treeSize) * 40 : 0);
ui.log(` pivot ${next}: ${col.individuals.size}/${col.treeSize} people, ${col.families.size} families`);
if (allFound()) {
phase = 2;
phase2Total = col.remainingToCheck();
ui.log(`All ${col.individuals.size} people found after ${jumps} jumps.`);
if (opts.thorough && phase2Total) ui.log(`Checking ${phase2Total} people whose branches were cut off in every view so far ...`);
}
} else {
const left = col.remainingToCheck();
ui.progress(40 + (phase2Total ? (1 - left / phase2Total) : 1) * 20);
ui.status(` ${left} people left to check, ${col.families.size} families`);
}
await sleep(delay);
}
ui.log(`Crawl done: ${jumps} requests (${refused} refused), ${col.individuals.size} people, ${col.families.size} families.`);
if (col.treeSize && col.individuals.size < col.treeSize) {
ui.log(` note: ${col.treeSize - col.individuals.size} people were not reachable through family links.`);
}
if (col.failed.size) ui.log(` note: ${col.failed.size} pivots failed - some family links may be missing: ${[...col.failed].join(', ')}`);
// 2) derived fields
col.computeLineageAndGeneration();
// 3) photos
const zip = testOnly ? null : new SimpleZip();
const withPhoto = [...col.individuals.values()].filter((r) => r.hasPhoto);
if (opts.photos) {
ui.log(`Downloading ${withPhoto.length} photos ...`);
let n = 0;
for (const rec of withPhoto) {
try { rec.photo = await col.downloadPhoto(rec, zip, true); }
catch (e) { ui.log(` photo ${rec.id} (${rec.name}) failed: ${e.message}`); }
n++;
ui.progress(60 + (n / withPhoto.length) * 35);
}
}
// 4) back to the home person so the page looks as before
try { await col.jumpTo(col.homeId); } catch (e) { /* ignore */ }
const json = col.toJson();
if (testOnly) return json;
// 5) zip + download
const base = 'MyHeritage_' + safeFileName(json.source.title).replace(/ /g, '_') + '_' + timestamp();
zip.file(base + '.json', JSON.stringify(json, null, 2));
ui.log('Packing ZIP ...');
const blob = await zip.generateAsync();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = base + '.zip';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
ui.progress(100);
ui.log(`Done: ${a.download} (${(blob.size / 1024).toFixed(0)} KB).`);
return json;
}
// expose for testing from the console: mhExport.run({photos:true, thorough:true}, null, true)
window.mhExport = {
run: (opts, ui, testOnly) => runExport(opts || { photos: true, thorough: true }, ui || { log, status: log, progress() {} }, testOnly),
TreeCollector
};
waitFor(treeReady, 60000, 250).then((ok) => {
if (!ok) { log('tree model never became ready; exporter button not shown'); return; }
buildUi();
log('exporter ready');
});
})();