☰

tensor.art profile download

try to take over the world!

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         tensor.art profile download
// @namespace    http://tampermonkey.net/
// @version      2026-10-09-9
// @description  try to take over the world!
// @author       You
// @match        https://tensor.art/u/*
// @match        https://tensorhub.art/en-US/u/*
// @match        https://tensorhub.art/u/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tensor.art
// @grant        GM_download
// @grant        GM.download
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      *
// @connect      api.tensor.art
// @connect      api.tensorhub.art
// @connect      tensor.art
// @connect      tensorhub.art
// ==/UserScript==
//https://api.tensor.art/community-web/v1/post/list
/* eslint-disable */
// eslint-disable-line
// eslint-disable-next-line(function() {
    'use strict';

const token = getCookie('ta_token_prod');
let cursor;
let hasMore = false; // API data.hasMore determines whether another page exists.
const originalUrl = window.location.href;
const currentURL = originalUrl.split('/');
const uIndex = currentURL.indexOf('u');
const userid = currentURL[uIndex + 1];

// Activity indicator shares the profile action row with the three buttons.
let activityBusy = false;
let activityMessage = '';
let activityRunning = false;

function setActivity(message, running = true) {
    activityMessage = message;
    activityRunning = running;
    const indicator = document.getElementById('tensor-activity-indicator');
    if (indicator) {
        indicator.querySelector('.tensor-activity-text').textContent = message;
        indicator.querySelector('.tensor-activity-spinner').hidden = !running;
        indicator.hidden = !message;
    }
    for (const id of ['tensor-download-button', 'tensor-index-button', 'tensor-nonliked-button']) {
        const button = document.getElementById(id);
        if (button) button.disabled = activityBusy;
    }
}

async function runActivity(label, action) {
    if (activityBusy) return;
    activityBusy = true;
    cursor = 0;
    hasMore = false;
    setActivity(`${label}: starting...`);
    try {
        const success = await action();
        setActivity(success === false ? `${label}: request failed` : (typeof success === 'string' ? success : `${label}: complete`), false);
    } catch (error) {
        console.error(`${label} failed`, error);
        setActivity(`${label}: error (see console)`, false);
    } finally {
        activityBusy = false;
        setActivity(activityMessage, false);
    }
}

function addActivityStyles() {
    if (document.getElementById('tensor-activity-styles')) return;
    const style = document.createElement('style');
    style.id = 'tensor-activity-styles';
    style.textContent = `
        .tensor-action-row { flex-wrap: wrap !important; align-items: center !important; }
        #tensor-activity-indicator {
            display: inline-flex !important;
            align-items: center;
            gap: 7px;
            box-sizing: border-box;
            flex: 0 1 auto;
            min-height: 30px;
            max-width: 100%;
            padding: 4px 9px;
            border: 1px solid rgba(128, 128, 128, .45);
            border-radius: 6px;
            background: rgba(128, 128, 128, .14);
            color: inherit;
            font-size: 12px;
            line-height: 18px;
            opacity: 1 !important;
            white-space: normal;
            overflow-wrap: anywhere;
            vertical-align: middle;
        }
        #tensor-activity-indicator[hidden], .tensor-activity-spinner[hidden] { display: none !important; }
        .tensor-activity-spinner {
            display: inline-block;
            flex: 0 0 13px;
            width: 13px;
            height: 13px;
            box-sizing: border-box;
            border: 2px solid currentColor;
            border-right-color: transparent;
            border-radius: 50%;
            animation: tensor-activity-spin .7s linear infinite;
        }
        @keyframes tensor-activity-spin { to { transform: rotate(360deg); } }
        #tensor-download-button:disabled, #tensor-index-button:disabled,
        #tensor-nonliked-button:disabled { opacity: .5; cursor: wait; }
    `;
    (document.head || document.documentElement).appendChild(style);
}

// Add controls beside the profile action buttons on other profiles and your own.
function addProfileButtons() {
    const editProfileLink = document.querySelector('a[href="/settings/profile"]');
    const ownProfileShareIcon = editProfileLink?.parentElement?.querySelector('button.vi-button iconpark-icon[icon-id="share"]');
    const ownProfileShareButton = ownProfileShareIcon?.closest('button');
    const flagIcon = document.querySelector('button.vi-button iconpark-icon[icon-id="flag"]');
    const flagButton = flagIcon?.closest('button');
    // Own profile: Edit Profile + Share. Other profiles: Share + Report.
    const actionButton = ownProfileShareButton || flagButton;
    if (!actionButton || document.getElementById('tensor-download-button')) return;
    addActivityStyles();
    actionButton.parentElement.classList.add('tensor-action-row');

    function makeButton(id, label, title, action, askPages = false) {
        const button = document.createElement('button');
        button.id = id;
        button.type = 'button';
        button.className = 'vi-button vi-button--size-normal vi-button--type-secondary';
        button.title = title;
        const wrap = document.createElement('div');
        wrap.className = 'vi-button__wrap';
        wrap.textContent = label;
        button.appendChild(wrap);
        button.addEventListener('click', () => {
            let pages = 0; // Index and Unliked always process all pages.
            if (askPages) {
                const input = prompt('Number of pages (0 = all):', '1');
                if (input === null) return;
                pages = Number(input.trim());
                if (!Number.isSafeInteger(pages) || pages < 0 || input.trim() === '') {
                    alert('Enter a whole number of pages (0 = all).');
                    return;
                }
            }
            runActivity(label, () => action(pages));
        });
        return button;
    }

    const indicator = document.createElement('span');
    indicator.id = 'tensor-activity-indicator';
    indicator.setAttribute('role', 'status');
    indicator.setAttribute('aria-live', 'polite');
    indicator.innerHTML = '<span class="tensor-activity-spinner" aria-hidden="true"></span><span class="tensor-activity-text"></span>';

    actionButton.after(
        indicator,
        makeButton('tensor-download-button', 'DL', 'Download images and metadata as individual files', pages => dlimages(false, pages), true),
        makeButton('tensor-index-button', 'Index', 'Build image HTML index (all pages)', () => buildImageHtml(false, false)),
        makeButton('tensor-nonliked-button', 'Unliked', 'Build unliked HTML index (all pages)', () => buildImageHtml(false, true))
    );
    if (!activityMessage) setActivity('Ready', false);
    else setActivity(activityMessage, activityRunning);
}

addProfileButtons();
new MutationObserver(addProfileButtons).observe(document.documentElement, { childList: true, subtree: true });

function getCookie(name) {
  const nameEQ = name + "=";
  const ca = document.cookie.split(';');

  for (let i = 0; i < ca.length; i++) {
    let c = ca[i];

    while (c.charAt(0) === ' ') {
      c = c.substring(1, c.length);
    }
    if (c.indexOf(nameEQ) === 0) {
      return decodeURIComponent(c.substring(nameEQ.length, c.length));
    }
  }
  return null;
}

async function fetchTensorPosts(maxRetries = 5) {
    let result;

    const data = "{\"cursor\":\""+cursor+"\",\"filter\":{},\"size\":\"40\",\"userId\":\""+ userid +"\",\"sort\":\"NEWEST\",\"visibility\":\"DEFAULT\"}";

    for (let attempt = 1; attempt <= maxRetries; attempt++) {

        try {

            const r = await GM.xmlHttpRequest({
                method: "POST",
                url: "https://api." + currentURL[2] + "/community-web/v1/post/list",
                headers: {
                    "User-Agent": "yo-momma",
                    "Accept": "*/*",
                    "Accept-Language": "en-US,en;q=0.5",
                    "Content-Type": "application/json",
                    "Content-Length": data.length,
                    "Referer": originalUrl,
                    "X-Request-Sign": "ZjIyN2UxOThkYjMwYzA3MzI5YjI3OTRjYTIxZGMyZDhjMGE5ODU3ZjZlNmE2ZDFmNGU1MzY3OTkyYmQ0OTk2Mg==",
                    "X-Request-Timestamp": "1764723083451",
                    "X-Request-Package-Sign-Version": "0.0.1",
                    "X-Request-Sign-Version": "v1",
                    "X-Request-Sign-Type": "HMAC_SHA256",
                    "X-Request-Package-Id": "3000",
                    "Origin": originalUrl,
                    "Sec-Fetch-Dest": "empty",
                    "Sec-Fetch-Mode": "cors",
                    "Sec-Fetch-Site": "same-site",
                    "x-echoing-env": "",
                    "Authorization": "Bearer " + token,
                    "Connection": "keep-alive",
                    "DNT": 1,
                    "Priority": "u=4",
                    "TE": "trailers"
                },
                data: data
            });

            if (!r || r.status !== 200 || !r.responseText) {
                console.warn(`Request failed attempt ${attempt}`, r);
                setActivity(`Request failed; retry ${attempt}/${maxRetries}...`);

                if (attempt < maxRetries) {
                    await sleep(1000 * attempt);
                    continue;
                }

                return null;
            }

            result = JSON.parse(r.responseText);
            console.log(result);
            cursor = result.data.cursor;
            hasMore = result.data.hasMore === true;

            return result.data.items;

        } catch(e) {

            console.error(`Request exception attempt ${attempt}`, e);
            setActivity(`Request error; retry ${attempt}/${maxRetries}...`);

            if (attempt < maxRetries) {
                await sleep(1000 * attempt);
                continue;
            }

            return null;
        }
    }

    return null;
}
async function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function dlimages(onePage, maxPages = 0) {
    // Start saving images as soon as each API page is received. Do not
    // discard already fetched images if a later API page fails.
    const profileName = document.querySelector('h1.fw-600.text-32.c-text-primary')?.textContent?.trim() || userid;
    const safeProfileName = profileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[. ]+$/g, '') || userid;
    const seenImages = new Set();
    const seenCursors = new Set();
    const failures = [];
    let pageCount = 0;
    let downloaded = 0;
    let failedPage = false;
    cursor = 0;
    hasMore = true;

    do {
        const cursorSent = String(cursor);
        if (seenCursors.has(cursorSent)) {
            console.error('DL repeated cursor:', cursorSent);
            failedPage = true;
            break;
        }
        seenCursors.add(cursorSent);
        setActivity(`DL: fetching page ${pageCount + 1} (${downloaded} saved)`);
        const items = await fetchTensorPosts();
        if (!items) {
            failedPage = true;
            break;
        }
        pageCount++;
        const pageImages = [];
        for (const post of items) {
            for (const image of (Array.isArray(post.images) ? post.images : [])) {
                if (!image.url) continue;
                const key = String(image.id ?? image.url);
                if (seenImages.has(key)) continue;
                seenImages.add(key);
                pageImages.push(image);
            }
        }
        console.log(`DL DEFAULT: page ${pageCount}, new images ${pageImages.length}, hasMore ${hasMore}, cursor ${cursor}`);
        // The original script used GM_download for each file. Keep that
        // mechanism and start these downloads immediately, page by page.
        downloaded += await downloadCollectedImages(pageImages, safeProfileName, failures, downloaded);
        if (onePage || (maxPages > 0 && pageCount >= maxPages) || !hasMore) break;
        if (cursor === null || cursor === undefined || String(cursor) === cursorSent) {
            console.error('DL: hasMore is true but no new cursor was returned:', cursor);
            failedPage = true;
            break;
        }
    } while (hasMore);

    if (failures.length) {
        console.warn('DL failures:', failures);
        const reportUrl = URL.createObjectURL(new Blob([failures.join('\n')], { type: 'text/plain' }));
        try {
            await gmDownloadOriginal({ url: reportUrl, name: `${safeProfileName}/FAILED_DOWNLOADS.txt` });
        } catch (error) {
            console.warn('Could not save FAILED_DOWNLOADS.txt:', error);
        } finally {
            URL.revokeObjectURL(reportUrl);
        }
    }
    const message = `DL: ${downloaded} saved from ${pageCount} page(s)` +
        (failures.length ? `, ${failures.length} failed` : '') +
        (failedPage ? ' (pagination incomplete; see console)' : '');
    setActivity(message, false);
    return downloaded ? message : false;
}

// Use the same GM_download interface as the original working script.
function gmDownloadOriginal(options) {
    return new Promise((resolve, reject) => {
        try {
            GM_download({
                ...options,
                saveAs: false,
                conflictAction: 'overwrite',
                onload: resolve,
                onerror: error => reject(new Error(String(error?.error || error?.details || 'Download failed'))),
                ontimeout: () => reject(new Error('Download timed out'))
            });
        } catch (error) {
            reject(error);
        }
    });
}

async function downloadCollectedImages(images, folder, failures, previouslySaved = 0) {
    let next = 0;
    let finished = 0;
    let saved = 0;
    const usedNames = new Set();

    function getExtension(url) {
        try {
            const match = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
            if (match) return match[1].toLowerCase();
        } catch (_) { /* use jpg as fallback */ }
        return 'jpg';
    }

    async function saveWithRetries(options) {
        let lastError;
        for (let attempt = 1; attempt <= 3; attempt++) {
            try { return await gmDownloadOriginal(options); }
            catch (error) {
                lastError = error;
                console.warn(`GM_download failed (${attempt}/3):`, options.name, error);
                if (attempt < 3) await sleep(750 * attempt);
            }
        }
        throw lastError;
    }

    async function worker() {
        while (next < images.length) {
            const item = images[next++];
            const ext = getExtension(item.url);
            const base = String(item.id ?? 'image').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || 'image';
            let stem = base;
            let suffix = 2;
            while (usedNames.has(stem)) stem = `${base}_${suffix++}`;
            usedNames.add(stem);
            const imageName = `${folder}/${stem}.${ext}`;
            try {
                await saveWithRetries({ url: item.url, name: imageName });
                saved++;
                // Just like the original code, save metadata only after
                // its associated image has successfully downloaded.
                const metadataUrl = URL.createObjectURL(new Blob([JSON.stringify(item)], { type: 'text/plain' }));
                try {
                    await saveWithRetries({ url: metadataUrl, name: `${folder}/${stem}.json` });
                } catch (error) {
                    failures.push(`${item.id ?? 'unknown'} | metadata | ${error.message}`);
                } finally {
                    URL.revokeObjectURL(metadataUrl);
                }
            } catch (error) {
                failures.push(`${item.id ?? 'unknown'} | ${item.url} | ${error.message}`);
                console.error('DL image failed:', imageName, error);
            }
            finished++;
            setActivity(`DL: ${previouslySaved + saved} saved; page ${finished}/${images.length}; ${failures.length} errors`);
        }
    }
    await Promise.all(Array.from({ length: Math.min(4, images.length) }, () => worker()));
    return saved;
}

async function buildImageHtml(onePage, onlyLiked) {
    let posts = [];
    let unlistedPosts = [];
    const collected = new Map();
    let pageCount = 0;
    let fetchFailed = false;

    function buildRows(postArray) {
        return postArray.map(post => {
            // Preserve the original Tensor.Art thumbnail handling: MP4 files
            // need a CDN-generated video frame, not an <img> with an MP4 URL.
            const thumbUrl = post.imageUrl ? post.imageUrl.split('/').pop().split(/[?#]/)[0] : '';
            const thumb = thumbUrl
                ? (thumbUrl.toLowerCase().includes('mp4')
                    ? `https://image.tensorartassets.com/cdn-cgi/media/mode=frame,time=1s,width=200/posts/images/${userid}/${thumbUrl}`
                    : `https://image.tensorartassets.com/cdn-cgi/image/anim=false,plain=false,w=200,f=avif,q=85/posts/images/${userid}/${thumbUrl}`)
                : (typeof post.snapshot === 'string' ? post.snapshot : '');
            const link = onlyLiked && post.imageId
                ? `https://tensor.art/images/${post.imageId}?post_id=${post.postId}`
                : (post.imageUrl || `https://tensor.art/u/${userid}/posts`);

            return `
            <div class="card">
                <a href="${link}" target="_blank">
                   ${thumb ? `<img src="${thumb}" loading="lazy">` : '<div>No image in API response</div>'}
                </a>
                <div class="meta">
                    <div data-post-id="${post.postId}" data-image-id="${post.imageId || ''}"><strong>ID:</strong> ${post.postId}<br>${post.postSize} images</div>
                </div>
            </div>`;
        }).join('\n');
    }

    // All index operations use DEFAULT visibility only.
    cursor = 0;
    hasMore = true;
    const seenCursors = new Set();
    do {
        setActivity(`${onlyLiked ? 'Unliked' : 'Index'}: DEFAULT page ${pageCount + 1} (${collected.size} posts)`);
        const cursorSent = String(cursor);
        if (seenCursors.has(cursorSent)) {
            console.error('Repeated pagination cursor, stopping to avoid a loop:', cursorSent);
            fetchFailed = true;
            break;
        }
        seenCursors.add(cursorSent);
        const items = await fetchTensorPosts();
        if (!items) {
            fetchFailed = true;
            break;
        }
        pageCount++;
        for (const post of items) {
            const postId = String(post.id ?? '');
            if (!postId) {
                console.warn('API post missing ID:', post);
                continue;
            }
            const existing = collected.get(postId);
            if (!existing) {
                collected.set(postId, { ...post, images: Array.isArray(post.images) ? [...post.images] : [] });
            } else {
                const imageKeys = new Set(existing.images.map(img => String(img.id ?? img.url)));
                for (const img of (Array.isArray(post.images) ? post.images : [])) {
                    const key = String(img.id ?? img.url);
                    if (!imageKeys.has(key)) {
                        imageKeys.add(key);
                        existing.images.push(img);
                    }
                }
                if (!existing.statisticsInfo && post.statisticsInfo) existing.statisticsInfo = post.statisticsInfo;
            }
        }
        console.log(`Index DEFAULT: page ${pageCount}, posts ${items.length}, hasMore ${hasMore}, cursor ${cursor}`);
        if (hasMore && (cursor === null || cursor === undefined || String(cursor) === cursorSent)) {
            console.error('API indicates hasMore but supplied no new cursor:', cursor);
            fetchFailed = true;
            break;
        }
        if (onePage) break;
        await sleep(500);
    } while (hasMore);

    if (fetchFailed) {
        console.error('Index incomplete. Not saving a partial index. See API errors above.');
        setActivity(`${onlyLiked ? 'Unliked' : 'Index'}: incomplete; check console`, false);
        return false;
    }

    for (const post of collected.values()) {
        const images = Array.isArray(post.images) ? post.images : [];
        const liked = post.statisticsInfo?.myLike != null;
        if (onlyLiked && liked) continue;
        if (!images.length) {
            // Index is also a post inventory, so show image-free posts rather
            // than silently omitting them. Unliked retains its original behavior.
            if (!onlyLiked) posts.push({ postId: post.id, postSize: 0 });
            continue;
        }
        const selectedImages = onlyLiked ? images.slice(0, 1) : images;
        for (const img of selectedImages) {
            const entry = {
                postId: post.id,
                imageId: img.id,
                imageUrl: img.url,
                snapshot: img.snapshot,
                postSize: onlyLiked ? images.length : 1,
                status: post.status,
                liked
            };
            posts.push(entry);
            if (!onlyLiked && img.contentRating === 'MATURE') unlistedPosts.push(entry);
        }
    }
    setActivity(`${onlyLiked ? 'Unliked' : 'Index'}: generating HTML (${collected.size} posts / ${posts.length} entries)`);
    console.log(`Finished ${pageCount} API pages; ${collected.size} distinct posts; ${posts.length} index entries`);

    const rows = buildRows(posts);
    const unlistedRows = buildRows(unlistedPosts);
    const profileName = document.querySelector('h1.fw-600.text-32.c-text-primary')?.textContent?.trim() || userid;
    const safeProfileName = profileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[. ]+$/g, '') || userid;
    const htmlProfileName = profileName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

    const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${onlyLiked ? 'Unliked' : 'Tensor'} Image Links - ${htmlProfileName} (${userid})</title>
<style>
body {
    background: #111;
    color: #eee;
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 20px;
}
.grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 20px;
}
.card {
    background: #222;
    border-radius: 8px;
    padding: 10px;
}
.card img {
    width: 100%;
    aspect-ratio: 1 / 1;
    height: auto;
    object-fit: contain;
    background: #111;
    display: block;
}
.meta {
    margin-top: 10px;
    word-break: break-all;
}
a { color: #66ccff; }
</style>
</head>
<body>
<h1>Tensor Post Links</h1>
<p>Posts: ${collected.size} | Entries: ${posts.length} | API pages: ${pageCount}</p>
${unlistedPosts.length > 0
    ? '<p><a href="unlisted.html">Unlisted Posts</a></p>'
    : ''
}
${onlyLiked ? '' : `<button id="download-links" type="button">Download linked files</button>
<span id="download-links-status" role="status" aria-live="polite"></span>`}
<div class="grid">
${rows}
</div>
${onlyLiked ? '' : `<script>
document.getElementById('download-links').addEventListener('click', async function () {
    const button = this;
    const status = document.getElementById('download-links-status');
    const urls = [...new Set(Array.from(document.querySelectorAll('.card a[href]'), a => a.href))];
    // Browsers without directory access fall back to regular downloads.
    // Browser downloads cannot reliably create a profile-named subfolder.
    if (!window.showDirectoryPicker) {
        button.disabled = true;
        let requested = 0;
        let directLinks = 0;
        try {
            for (let i = 0; i < urls.length; i++) {
                const url = urls[i];
                const pathname = new URL(url).pathname;
                let name = decodeURIComponent(pathname.split('/').pop() || '') || 'image-' + (i + 1);
                name = name.replace(/[<>:"|?*]/g, '_').replaceAll('/', '_').replaceAll(String.fromCharCode(92), '_').replace(/[\\x00-\\x1f]/g, '_').replace(/[. ]+$/g, '') || 'image-' + (i + 1);
                let href = url;
                let blobUrl = null;
                try {
                    const response = await fetch(url);
                    if (!response.ok) throw new Error('HTTP ' + response.status);
                    blobUrl = URL.createObjectURL(await response.blob());
                    href = blobUrl;
                } catch (error) {
                    // A cross-origin server may disallow fetch; try its URL
                    // as a normal browser download instead.
                    directLinks++;
                }
                const link = document.createElement('a');
                link.href = href;
                link.download = name;
                // An ignored cross-origin download attribute may open a tab;
                // keep the generated index page intact.
                if (!blobUrl) link.target = '_blank';
                document.body.appendChild(link);
                link.click();
                link.remove();
                if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
                requested++;
                status.textContent = 'Requested ' + requested + ' / ' + urls.length + ' downloads' +
                    (directLinks ? ' (' + directLinks + ' direct links)' : '');
                await new Promise(resolve => setTimeout(resolve, 300));
            }
            status.textContent = 'Requested ' + requested + ' downloads to browser download location' +
                (directLinks ? '; ' + directLinks + ' direct links may open instead of saving' : '') + '.';
        } catch (error) {
            console.error('Index browser downloads failed', error);
            status.textContent = 'Download stopped: ' + error.message;
        } finally {
            button.disabled = false;
        }
        return;
    }

    // A download attribute cannot create folders; ask the user to select a
    // parent directory and create a profile-named subdirectory inside it.
    let parent;
    try {
        parent = await window.showDirectoryPicker({ mode: 'readwrite' });
    } catch (error) {
        if (error.name !== 'AbortError') status.textContent = 'Folder selection failed: ' + error.message;
        return;
    }

    button.disabled = true;
    let saved = 0;
    const failures = [];
    try {
        const folderName = ${JSON.stringify(safeProfileName)};
        const folder = await parent.getDirectoryHandle(folderName, { create: true });
        const usedNames = new Set();
        for (let i = 0; i < urls.length; i++) {
            const url = urls[i];
            try {
                const pathname = new URL(url).pathname;
                let name = decodeURIComponent(pathname.split('/').pop() || '') || 'image-' + (i + 1);
                name = name.replace(/[<>:"|?*]/g, '_').replaceAll('/', '_').replaceAll(String.fromCharCode(92), '_').replace(/[\\x00-\\x1f]/g, '_').replace(/[. ]+$/g, '') || 'image-' + (i + 1);
                // Avoid overwriting another entry with the same URL basename.
                const dot = name.lastIndexOf('.');
                const stem = dot > 0 ? name.slice(0, dot) : name;
                const ext = dot > 0 ? name.slice(dot) : '';
                let uniqueName = name;
                let suffix = 2;
                while (usedNames.has(uniqueName.toLowerCase())) uniqueName = stem + '-' + suffix++ + ext;
                const response = await fetch(url);
                if (!response.ok) throw new Error('HTTP ' + response.status);
                const handle = await folder.getFileHandle(uniqueName, { create: true });
                const writable = await handle.createWritable();
                try {
                    if (response.body) await response.body.pipeTo(writable);
                    else { await writable.write(await response.blob()); await writable.close(); }
                } catch (error) {
                    await writable.abort().catch(() => {});
                    throw error;
                }
                usedNames.add(uniqueName.toLowerCase());
                saved++;
            } catch (error) {
                failures.push(url + ' — ' + error.message);
            }
            status.textContent = 'Saved ' + saved + ' / ' + urls.length + ' to ' + folderName + '/ (' + failures.length + ' failed)';
        }
        if (failures.length) {
            console.warn('Index downloads that failed:', failures);
            const failureFile = await folder.getFileHandle('FAILED_DOWNLOADS.txt', { create: true });
            const output = await failureFile.createWritable();
            await output.write(failures.join('\\n'));
            await output.close();
        }
        status.textContent = 'Finished: ' + saved + ' saved in ' + folderName + '/'  + (failures.length ? ', ' + failures.length + ' failed (see FAILED_DOWNLOADS.txt)' : '');
    } catch (error) {
        console.error('Index folder download failed', error);
        status.textContent = 'Download failed: ' + error.message;
    } finally {
        button.disabled = false;
    }
});
</script>`}
</body>
</html>`;
    const blob = new Blob([html], { type: 'text/html' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = onlyLiked ? `${safeProfileName}.html` : 'index.html';
    link.dispatchEvent(new MouseEvent('click'));
    window.setTimeout(() => window.URL.revokeObjectURL(url), 1000);

    if (unlistedPosts.length > 0) {
        const unlistedHtml = html.replace(
            /<title>[^<]*<\/title>/,
            '<title>Unlisted Tensor Image Links - ' + htmlProfileName + ' (' + userid + ')</title>'
        ).replace(
            '<h1>Tensor Post Links</h1>',
            '<h1>Unlisted Tensor Post Links</h1>'
        ).replace(rows, unlistedRows).replace('<p><a href="unlisted.html">Unlisted Posts</a></p>','<p><a href="index.html">All Posts</a></p>');
        const unlistedBlob = new Blob([unlistedHtml], { type: 'text/html' });
        const unlistedUrl = URL.createObjectURL(unlistedBlob);
        const unlistedLink = document.createElement('a');
        unlistedLink.href = unlistedUrl;
        unlistedLink.download = 'unlisted.html';
        unlistedLink.dispatchEvent(new MouseEvent('click'));
        window.setTimeout(() => window.URL.revokeObjectURL(unlistedUrl), 1000);
    }
}