☰

tensor.art profile download

try to take over the world!

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
    }
}