try to take over the world!
// ==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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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);
}
}