Как скачать свои закладки

Большое обновление

Пример работы
Обновление 26.03.24
  • Теперь можно сохранять комментарии к постам
  • Теперь результаты опросов будут видны
Обновление 20.03.24
  • Теперь можно сохранять посты пользователей
  • Теперь используются оригинальные стили
  • Добавлен выбор темы в параметрах
  • Теперь работают видео
  • Добавлен скрипт для подгрузки видео YouTube
  • Добавлена возможность сохранять стили офлайн
  • Добавлена возможность сохранять картинки офлайн
  • Теперь токен не нужно заново вводить после каждого запроса
  • Теперь будет показываться ссылка на веб-архив, если она есть
  • Теперь ссылки будут открываться в новой вкладке
  • Добавлена возможность параллельной загрузки страниц

Шаг 1. Установить скрипт

Скачать расширение Tamperermonkey

Нажать на иконку расширения. Выбрать "Создать новый скрипт".

Заменить код на этот. Нажать "Файл > Сохранить".

// ==UserScript== // @name DTF Save Bookmarks // @namespace http://tampermonkey.net/ // @version 2024-03-26 // @description Быстро скачать закладки // @author Demon59901 // @match https://dtf.ru/* // @icon https://www.google.com/s2/favicons?sz=64&domain=dtf.ru // @grant none // ==/UserScript== // НАСТРОЙКИ const THEME = 'auto' // auto, dark, light const SAVE_COMMENTS = true const COMMENT_LIMIT = 10000 const OFFLINE_STYLES = true const OFFLINE_IMAGES = true const BOOKMARKS_HTML = false const USE_DATE_PREFIX = true const LINK_TO_ARCHIVE = true const PARALLEL_DOWNLOADS = true const MAX_PARALLEL_DOWNLOADS = 2 // <= 5 // Время на загрузку DOM const DELAY = 1000 // ======== let prevToken = '' const imageCache = new Map() const downloadStartEvent = new Event('downloadStart') const downloadStopEvent = new Event('downloadStop') function setup() { const url = window.location.href const profileMatch = url.match(/dtf\.ru\/u\/(\d+)(?:-[\w-]+)?\/?$/) const bookmarksMatch = url.match(/dtf\.ru\/bookmarks(?:\/posts)?\/?$/) if (!profileMatch && !bookmarksMatch) return const placeForButton = document.querySelector('.bookmarks__filter, .subsite-feed-sorting') if (!placeForButton || placeForButton.querySelector('#download-button')) return placeForButton.style.display = 'flex' placeForButton.style.alignItems = 'baseline' placeForButton.style.justifyContent = 'space-between' const downloadButton = document.createElement('button') const buttonText = `Скачать ${bookmarksMatch ? 'закладки' : 'посты'}` downloadButton.id = 'download-button' downloadButton.innerText = buttonText downloadButton.style.fontSize = '15px' downloadButton.style.cursor = 'pointer' downloadButton.style.color = 'color: var(--theme-color-text-primary)' downloadButton.className = 'sidebar-item-inline' downloadButton.onclick = () => bookmarksMatch ? downloadBookmarks() : downloadPosts() downloadButton.addEventListener('downloadStart', e => { e.target.innerText = 'Загрузка...' e.target.disabled = true e.target.style.opacity = '0.5' }) downloadButton.addEventListener('downloadStop', e => { e.target.innerText = buttonText e.target.disabled = false e.target.style.removeProperty('opacity') }) placeForButton.appendChild(downloadButton) async function downloadBookmarks() { try { const token = prompt('TOKEN', prevToken || 'Вставьте ваш токен авторизации') if (!token) return prevToken = token const count = parseInt(prompt('COUNT', '10000')) if (isNaN(count)) return const offset = parseInt(prompt('OFFSET', '0')) if (isNaN(offset)) return downloadButton.dispatchEvent(downloadStartEvent) const url = `https://api.dtf.ru/v2.5/bookmarks?type=posts&count=${count}&offset=${offset}` const headers = { 'Jwtauthorization': token } const result = (await (await fetchData(url, headers)).json()).result const posts = result.map(({ data: { id, url, title, date }, date: saveDate }) => ({ id, url, title: title || url, date, prefixDate: saveDate })) await processPosts(posts) } catch (error) { alert(error) } finally { downloadButton.dispatchEvent(downloadStopEvent) } } async function downloadPosts() { try { const count = parseInt(prompt('COUNT', '10000')) if (isNaN(count)) return const offset = parseInt(prompt('OFFSET', '0')) if (isNaN(offset)) return downloadButton.dispatchEvent(downloadStartEvent) const userId = profileMatch[1] const sorting = 'new' let postItems = [] let end = null do { const url = `https://api.dtf.ru/v2.5/timeline?&sorting=${sorting}&subsitesIds=${userId}${end ? `&lastId=${end}` : ''}` const { lastId, items } = (await (await fetchData(url)).json()).result postItems.push(...items) end = lastId } while (end !== 0 && postItems.length < (offset + count)) postItems = postItems.splice(offset, count) const posts = postItems.map(({ data: { id, title, date, subsite: { uri: subsiteUri } } }) => { const baseUrl = new URL('https://dtf.ru') const url = new URL(`${subsiteUri ? subsiteUri : `/u/${userId}`}/${id}`, baseUrl).href return { url, id, title: title || url, date, prefixDate: date } }) await processPosts(posts) } catch (error) { alert(error) } finally { downloadButton.dispatchEvent(downloadStopEvent) } } async function processPosts(posts) { if (!posts.length) { alert('Ничего не найдено') return } if (BOOKMARKS_HTML && confirm(`Сохранить найденные ссылки (${posts.length}) в bookmarks.html?`)) createBookmarksHtml(posts) if (confirm(`Сохранить ${posts.length} постов в папку загрузок?`)) { downloadButton.dispatchEvent(downloadStartEvent) await (PARALLEL_DOWNLOADS ? downloadPagesInParallel(posts) : downloadPagesSequentially(posts)) downloadButton.dispatchEvent(downloadStopEvent) console.log('✅ DONE') } } } async function downloadPage({ id, url, title, date, prefixDate }, index, total) { try { console.log(`⬇️ Downloading ${index + 1} of ${total}:`, { title, url }) const doc = new DOMParser().parseFromString(await (await fetchData(url)).text(), 'text/html') const baseUrl = new URL(url) doc.querySelectorAll('[href], [src]').forEach(el => { el.href && (el.href = new URL(el.href, baseUrl).href) el.src && (el.src = new URL(el.src, baseUrl).href) }) const content = doc.querySelector('.content') if (!content) throw new Error('Блок с контентом не найден') content.classList.remove('content--nsfw') content.querySelectorAll('.block-wrapper__overlay, .content-nsfw, .content-footer__share, .content-footer__item--dislike') // .content__counters, .content-footer, .forEach(el => el.remove()) const headerActions = content.querySelector('.content-header__actions') headerActions.appendChild(createLinkButton(baseUrl.href, 'Ссылка на пост')) const timestamp = 1670976000 // 14.12.2022 0:00:00 if (LINK_TO_ARCHIVE && date <= timestamp) { try { const { archived_snapshots: { closest: { url: archivedUrl = '' } = {} } } = await (await fetchData(`https://archive.org/wayback/available?url=${url}&timestamp=${timestamp}`)).json() archivedUrl && (headerActions.prepend(createLinkButton(archivedUrl, 'Ссылка на архив'))) } catch (error) { console.error(`Ошибка при попытке получить ссылку на веб-архив (${url}):`, error) } } content.querySelectorAll('time').forEach(time => time.innerText = time.title.slice(0, 10)) if (OFFLINE_IMAGES) { await replaceImagesWithBase64(content) content.querySelectorAll('picture').forEach(el => { const img = el.querySelector('img') img && img.src.startsWith('data:image') && el.querySelectorAll('source').forEach(source => source.remove()) }) } else { content.querySelectorAll('[data-loaded=true]').forEach(el => el.dataset.loaded = 'false') } let mediaSrc = {} const fallback = { youtube: 'https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&playsinline=1', video: 'https://leonardo.osnova.io/0f80ee3e-bb93-5788-a578-336570c3fb2d/-/format/mp4#t=0.1' } try { const { author: { avatar: { data: { uuid: avatarId } } }, blocks } = (await (await fetchData(`https://api.dtf.ru/v2.5/content?id=${id}`)).json()).result await Promise.all(blocks.map(block => gatherMediaSrc(block, mediaSrc))) content.querySelectorAll('.author__avatar video') .forEach(avatar => avatar.src = `https://leonardo.osnova.io/${avatarId}`) content.querySelectorAll('.content__blocks video') .forEach((video, idx) => video.src = mediaSrc.gif[idx] || fallback.video) content.querySelectorAll('.block-quiz').forEach((quiz, idx) => { try { const quizData = mediaSrc.quiz[idx] quiz.querySelector('.block-quiz__options').classList.add('block-quiz__options--disabled') quiz.querySelector('.block-quiz__footer span').innerText = `${Object.values(quizData)[0].total} голосов` quiz.querySelectorAll('.block-quiz-option').forEach(option => { const title = option.querySelector('.block-quiz-option__text').innerText const optionData = quizData[title] const control = option.querySelector('.block-quiz-option__control') const progress = option.querySelector('.block-quiz-option__progress') control.className = 'block-quiz-option__value' control.innerText = `${optionData.percentage}%` progress.classList.add('block-quiz-option__progress--minimal') progress.style = `--percentage: ${optionData.percentage}%;` }) } catch (error) { console.error(`Ошибка при заполнении результатов опроса (${url}):`, error) } }) } catch (error) { console.error(`Ошибка при загрузке данных (${url}):`, error) } if (!mediaSrc.video) { content.querySelectorAll('.andropov-external-video__overlay').forEach(() => { mediaSrc.video = mediaSrc.video || [] mediaSrc.video.push(fallback.youtube) }) } const linkToMainCss = getLinkToCss(/\/assets\/index-\w+?\.css$/i) const linkToCommentsCss = getLinkToCss(/\/assets\/comments-\w+?\.css$/i) const offlineMainStyles = OFFLINE_STYLES && await getCachedStyles(linkToMainCss, '--cached-main-css') const offlineCommentsStyles = SAVE_COMMENTS && OFFLINE_STYLES && await getCachedStyles(linkToCommentsCss, '--cached-comments-css') const commentsHtml = SAVE_COMMENTS && await getComments() || '' const headHtml = ` <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no"> <title>${title}</title> ${offlineMainStyles ? `<style>${offlineMainStyles}</style>` : `<link rel="stylesheet" href="${linkToMainCss}">`} ${offlineCommentsStyles ? `<style>${offlineCommentsStyles}</style>` : `<link rel="stylesheet" href="${linkToCommentsCss}">`} <style> body { height: auto; padding: var(--layout-view-offset-y) 0; word-break: break-word; } .content, .comments { max-width: var(--layout-max-content-width); margin: 0 auto; } .comments { margin-top: var(--layout-island-gap-y); } </style> ` const script = ` <script> const videoSrc = ${JSON.stringify(mediaSrc.video)} const imageSrc = ${JSON.stringify(mediaSrc.image)} document.querySelectorAll('a[href]').forEach(link => link.target = '_blank') document.querySelectorAll('.andropov-external-video__overlay').forEach((video, idx) => { video.addEventListener('click', () => { const player = document.createElement('iframe') player.classList.add('andropov-external-video__player') player.allow = 'autoplay;accelerometer;clipboard-write;encrypted-media;gyroscope;picture-in-picture' player.allowFullscreen = true player.src = videoSrc[idx] video.parentElement.replaceChildren(player) requestAnimationFrame(() => { player !== null && (player.allow = player.allow.replace('autoplay;', '')) }) }) }) document.querySelectorAll('.media--zoom, .gallery__item, .comment-media .andropov-image--zoom').forEach((img, idx) => { img.querySelector('.gallery__more')?.remove() img.addEventListener('click', () => { window.open(imageSrc[idx], '_blank') }) }) document.querySelectorAll('[data-loaded=false] img, video').forEach(el => { const eventType = el.tagName === 'IMG' ? 'load' : 'loadeddata' el.addEventListener(eventType, () => { requestAnimationFrame(() => { el.closest('[data-loaded]').dataset.loaded = 'true' }) }, { once: true }) }) setTimeout(() => document.querySelectorAll('[data-loaded=false]').forEach(el => el.dataset.loaded = 'true'), 5000) </script> ` const icons = ` <svg id="__svg__icons__dom__" xmlns="http://www.w3.org/2000/svg" xmlns:link="http://www.w3.org/1999/xlink" style="position: absolute; width: 0px; height: 0px;"><symbol fill="none" viewBox="0 0 24 24" id="bookmark"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 7a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.045c0 1.628-1.875 2.539-3.155 1.534l-4.32-3.395a.85.85 0 0 0-1.05 0l-4.32 3.395C5.875 21.584 4 20.672 4 19.045V7Zm4-2a2 2 0 0 0-2 2v11.942l4.24-3.33a2.85 2.85 0 0 1 3.52 0l4.24 3.33V7a2 2 0 0 0-2-2H8Z" fill="currentColor"></path></symbol><symbol fill="none" viewBox="0 0 24 24" id="comment"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.979 11.736c0-5.4-4.383-8.736-9.244-8.736C6.883 3 2.518 6.323 2.5 11.703c-.018 5.38 4.269 8.768 9.236 8.768 1.306 0 2.353-.066 3.619-.57.191-.076.507-.082.81.02l3.044 1.013c.718.216 1.363-.124 1.723-.48.375-.372.707-1.018.511-1.742l-.007-.028-.998-2.992c-.1-.39-.1-.84-.017-1.07l.055-.155c.277-.768.503-1.393.503-2.731ZM11.735 5C7.754 5 4.5 7.653 4.5 11.736s3.254 6.735 7.236 6.735c1.22 0 1.966-.064 2.88-.428.73-.29 1.547-.232 2.184-.02l2.596.866-.875-2.622-.007-.029c-.168-.622-.246-1.535.024-2.29l.06-.168c.246-.682.38-1.054.38-2.044 0-4.082-3.26-6.736-7.243-6.736Z" fill="currentColor"></path></symbol><symbol fill="none" viewBox="0 0 24 24" id="like"><path d="M3.224 8.104C3.751 6.186 5.284 4.5 7.876 4.5c1.563 0 2.64.574 3.391 1 .305.173.558.377.733.5.175-.123.428-.327.733-.5.751-.426 1.828-1 3.39-1 2.592 0 4.126 1.686 4.653 3.604.519 1.887.045 4.248-1.276 5.896-1.218 1.518-2.8 2.809-4.212 3.786-.709.492-1.366.896-1.883 1.18-.419.23-.912.534-1.405.534-.5 0-.983-.302-1.405-.534a22.27 22.27 0 0 1-1.883-1.18C7.3 16.81 5.718 15.518 4.5 14c-1.321-1.648-1.795-4.009-1.276-5.896Z" stroke="currentColor" stroke-width="1.995" stroke-linecap="round" stroke-linejoin="round"></path></symbol><symbol viewBox="0 0 24 24" id="pin"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.166 4.235c.525-1.276 2.237-1.683 3.262-.653l3.99 4.012.003.002c1.02 1.019.628 2.736-.656 3.26-2.774 1.344-3.774 3.867-3.774 7.146 0 .91-.596 1.56-1.256 1.829-.653.265-1.53.22-2.16-.42L9.95 16.787 4.6 20.8a1 1 0 0 1-1.4-1.4l4.013-5.35-2.623-2.623-.002-.002c-.635-.629-.686-1.503-.42-2.16.269-.663.922-1.254 1.83-1.254 3.287 0 5.822-.998 7.168-3.776Zm1.847.768a1.011 1.011 0 0 1-.03.067c-1.8 3.756-5.255 4.936-8.96 4.941a.11.11 0 0 0-.005.016L9.24 13.25a1 1 0 0 1 .093 1.307L9 15.002l.446-.334a1 1 0 0 1 1.307.093l3.221 3.22a.12.12 0 0 0 .018-.007c.006-3.701 1.191-7.14 4.94-8.936.022-.01.044-.02.066-.028A.03.03 0 0 0 19 9.004L15.02 5a.037.037 0 0 0-.006.002Z"></path></symbol></svg> ` const blob = new Blob([`<!DOCTYPE html><html data-theme="${THEME}" lang="ru"><head>${headHtml}</head><body>${content.outerHTML}${commentsHtml}${script}${icons}</body></html>`], { type: 'text/html' }) const downloadUrl = window.URL.createObjectURL(blob) const a = document.createElement('a') const prefix = USE_DATE_PREFIX && !isNaN(parseInt(prefixDate)) ? `${new Date(prefixDate * 1000).toISOString().replace(/-/g, '.').slice(0, 10)} ` : '' a.href = downloadUrl a.download = `${prefix}${title}.html` document.body.appendChild(a) a.click() window.URL.revokeObjectURL(downloadUrl) a.remove() function createLinkButton(href, text) { const linkButton = document.createElement('a') linkButton.href = href linkButton.textContent = text linkButton.classList.add('button', 'button--size-s', 'button--type-minimal') return linkButton } async function replaceImagesWithBase64(element) { const imgPromises = Array.from(element.querySelectorAll('img')).map(async img => { try { let base64Image = imageCache.get(img.src) if (!base64Image) { const blob = await (await fetchData(img.src)).blob() base64Image = await blobToBase64(blob) imageCache.set(img.src, base64Image) } img.src = base64Image } catch (error) { console.error(`Ошибка при загрузке изображения (${img.src}, ${url}):`, error) imageCache.set(img.src, img.src) } }) await Promise.all(imgPromises) } function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.readAsDataURL(blob) reader.onloadend = () => resolve(reader.result) reader.onerror = reject }) } async function gatherMediaSrc({ type, data }, srcMap) { try { switch (type) { case 'media': data.items.forEach(({ image: { data } }, _, arr) => imageDataHandler(srcMap, data, arr.length)) break case 'video': const { name, id } = data.video?.data.external_service || data.external_service srcMap.video = srcMap.video || [] srcMap.video.push(getExternalSrc(name, id)) break case 'osnovaEmbed': data.osnovaEmbed.data.image?.data && imageDataHandler(srcMap, data.osnovaEmbed.data.image.data, 0) break case 'tweet': data.tweet.data.tweet_data.extended_entities?.media?.forEach(img => { srcMap.image = srcMap.image || [] srcMap.image.push(img.media_url) }) break case 'image' || 'movie': imageDataHandler(srcMap, data, 1) break case 'quiz': const { result: { items } } = await (await fetchData(`https://api.dtf.ru/v2.1/quiz/${data.hash}/results`)).json() srcMap.quiz = srcMap.quiz || [] const quizResult = Object.keys(data.items).reduce((acc, key) => { if (items.hasOwnProperty(key)) { acc[data.items[key]] = items[key] } return acc }, {}) srcMap.quiz.push(quizResult) break default: break } } catch (error) { console.error(`Ошибка при извлечении данных из объекта (${url}):\n${data}`, error) } } function imageDataHandler(srcMap, { type, uuid, external_service: { mp4_url } }, count = 1) { if ((type === 'gif' || type === 'mp4') && (count === 1 || count === 0)) { const src = mp4_url || `https://leonardo.osnova.io/${uuid}/-/format/mp4#t=0.1` srcMap.gif = srcMap.gif || [] srcMap.gif.push(src) } else if (count !== 0) { srcMap.image = srcMap.image || [] srcMap.image.push(`https://leonardo.osnova.io/${uuid}`) } } function getExternalSrc(name, id) { const n = window.location.hostname switch (name) { case 'youtube': return `https://www.youtube.com/embed/${id}?autoplay=1&playsinline=1` case 'coub': return `https://coub.com/embed/${id}?autoplay=true` case 'twitch-clip': return `https://clips.twitch.tv/embed?clip=${id}&parent=${n}&autoplay=true` default: return } } async function getComments() { try { const { result: { items: comments } } = await (await fetchData(`https://api.dtf.ru/v2.4/comments?sorting=hotness&contentId=${id}`)).json() if (comments.length === 0) return '' let commentsHtml = '' comments.slice(0, COMMENT_LIMIT).forEach(comment => { commentsHtml += insertComment(comment) comment.media.forEach(media => gatherMediaSrc(media, mediaSrc)) }) const commentsEl = new DOMParser().parseFromString(commentsHtml, 'text/html') if (OFFLINE_IMAGES) { await replaceImagesWithBase64(commentsEl) commentsEl.querySelectorAll('picture').forEach(el => { const img = el.querySelector('img') img && img.src.startsWith('data:image') && el.querySelectorAll('source').forEach(source => source.remove()) }) } else { commentsEl.querySelectorAll('[data-loaded=true]').forEach(el => el.dataset.loaded = 'false') } const commentsLeft = comments.length - COMMENT_LIMIT return ` <div class="comments${commentsLeft > 0 ? ' comments--limited' : ''}"> <div class="comments-header"> <div class="comments-header__title">${comments.length} комментариев</div> </div> <div class="comments-tree"> ${commentsEl.body.innerHTML} </div> ${commentsLeft <= 0 ? '' : ` <div class="comments-limit comments-limit--bottom"> <a href="${url}#comments" class="link-button link-button--default comments-limit__expand"> Ещё ${commentsLeft} в оригинальном посте </a> </div> `} </div> ` function insertComment({ id, author, date, likes, media, level, text, isPinned }) { const baseUrl = new URL('https://dtf.ru') const authorUrl = new URL(`${author.uri || `/u/${author.id}`}`, baseUrl).href const commentUrl = `${url}?comment=${id}` const time = new Date(date * 1000) const linkRegex = /(?:https?:\/\/)?(?![^@${}(),;:<>"\[\]\s]*[@${}(),;:<>"])([\w-]{1,32}\.[\w-]{1,32})(?:\S*)?/g const mentionRegex = /\[@(\d+)\|([^|\]]+)]/g const textHtml = text.replace(linkRegex, match => `<a href="${match}">${match}</a>`) .replace(mentionRegex, '<a href="https://dtf.ru/u/$1">@$2</a>') .split('\n\n').map(block => `<p>${block.split('\n').map((line, idx, arr) => line.startsWith('>') ? `<span class="quote">${line.slice(1)}</span>` : (idx === arr.length - 1 ? line : `${line}<br>`)).join('')}</p>` ).join('') return ` <div class="comment" style="--branches-count: ${level > 8 ? 8 : level}; --height: auto;"> <div class="comment__branches"></div> <div class="comment__content" data-content="true"> <div class="author" style="--20398234: 36px;"> <a class="author__avatar" href="${authorUrl}"> ${insertAvatar(author.avatar)} </a> <div class="author__main"> <a class="author__name" href="${authorUrl}">${author.name}</a> ${isPinned ? `<div class="comment__icon"><svg class="icon icon--pin" width="14" height="14"><use xlink:href="#pin"></use></svg></div>` : ''} </div> <div class="author__details"><a class="comment__detail" href="${commentUrl}"><time title="${time.toLocaleString().replace(',', ' в')}" datetime="${time.toISOString()}">${time.toLocaleString().slice(0, 10)}</time></a></div> </div> <div class="comment__text"> ${textHtml} </div> ${media && `<div class="comment-media">${media.reduce((acc, cur) => acc + insertMedia(cur), '')}</div>`} <div class="comment__actions"> <div class="reaction-root"> <button class="reaction reaction--like"> <div class="reaction__icon"> <svg class="icon icon--like" width="20" height="20"> <use xlink:href="#like"></use> </svg> </div> <span>${likes.counterLikes}</span> </button> </div> <a class="comment__action" href="${commentUrl}">Ответить</a> </div> </div> </div> ` } function insertAvatar({ data: { uuid, width, height, type, color, base64preview } }) { const src = `https://leonardo.osnova.io/${uuid}/-/scale_crop/72x72/-/format/webp` const style = `aspect-ratio: ${width / height}; width: 36px; height: 36px; max-width: none; --background-color: #${color};` return insertAvatarPicture({ src, style, type, base64preview }) } function insertAvatarPicture({ src, style }) { return ` <div data-loaded="false" class="andropov-media andropov-media--rounded andropov-media--bordered andropov-media--has-preview andropov-image" style="${style}"> <picture> <source srcset="${src}/-/scale_crop/72x72/-/format/webp" type="image/webp"> <img src="${src}/-/scale_crop/72x72/" alt="" loading="lazy"> </picture> </div> ` } function insertMedia({ type, data }) { switch (type) { case 'video': return insertExternalVideoThumbnail(data) case 'link': return insertMediaLink(data) case 'movie': case 'image': return data.type === 'gif' || data.type === 'mp4' ? insertMediaVideo(data) : insertMediaPicture(data) default: return insertMediaNotSupported() } } function insertMediaVideo({ uuid, width, height, external_service: { mp4_url } = {} }) { const src = mp4_url || `https://leonardo.osnova.io/${uuid}/-/format/mp4#t=0.1` const style = `aspect-ratio: ${width / height}; max-width: 400px; --background-color: #fff;` return ` <div data-loaded="false" class="andropov-media andropov-media--rounded andropov-media--bordered andropov-media--has-preview andropov-video" style="${style}"> <video preload="metadata" controls="true" playsinline="true" loop="" src="${src}" data-video="0"></video> </div> ` } function insertMediaPicture({ uuid, width, height, color }) { const src = `https://leonardo.osnova.io/${uuid}` const style = `aspect-ratio: ${width / height}; max-width: 400px; --background-color: #${color};` return ` <div data-loaded="false" class="andropov-media andropov-media--rounded andropov-media--bordered andropov-media--has-preview andropov-image andropov-image--zoom" style="${style}"> <picture> <source srcset="${src}/-/preview/400x/-/format/webp" type="image/webp"> <img src="${src}/-/preview/400x/" alt="" loading="lazy"> </picture> </div> ` } function insertExternalVideoThumbnail({ thumbnail: { data: { uuid, width, height, color, } }, external_service: { name: serviceName } }) { const src = `https://leonardo.osnova.io/${uuid}` const style = `aspect-ratio: ${width / height}; max-width: 400px; --background-color: #${color};` const youtubeLogo = ` <svg version="1.1" viewBox="0 0 68 48" width="68" height="48"> <path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path> <path d="M 45,24 27,14 27,34" fill="#fff"></path> </svg> ` const coubLogo = ` <svg width="36" height="49" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M19.72 4.123c.11-1.237-.23-2.302-.932-3.055-.695-.745-1.665-1.099-2.644-1.066C14.14.07 12.232 1.69 11.758 4.58c-.76 4.634-.581 12.421.178 20.094a10.56 10.56 0 00-2.7-1.338c-1.673-.561-3.858-.835-5.67-.489-.91.174-1.813.521-2.495 1.157C.357 24.67-.036 25.586.003 26.7c.029.844.583 1.398 1.08 1.733.501.338 1.135.588 1.732.79.384.13.81.259 1.215.381l.004.002.007.002.623.19c.603.19 1.106.368 1.48.562.618.32 1.248.931 1.912 1.8.659.862 1.287 1.888 1.94 2.965l.08.132v.001c.616 1.017 1.257 2.075 1.931 2.978.445.597.936 1.171 1.484 1.638a.996.996 0 00-.089.412v5.391c0 .95.673 1.792 1.642 1.956 4.742.805 10.791.83 14.354.029.926-.209 1.504-1.037 1.504-1.91v-5.37-.023c.288-.175.554-.389.757-.654.484-.633.99-1.746 1.455-2.991.479-1.282.957-2.818 1.36-4.38.402-1.562.734-3.169.915-4.591.176-1.383.228-2.724-.023-3.678-.18-.69-.644-1.252-1.16-1.7-.525-.455-1.186-.862-1.903-1.225-1.436-.729-3.24-1.355-5.001-1.87-2.848-.83-5.697-1.4-6.959-1.634-.342-1.792-1.195-7.144-.624-13.513zm9.182 36.988c-.18.043-.364.084-.552.122-1.405.284-3.127.441-4.87.484-1.747.043-3.548-.028-5.117-.213-1.085-.128-2.12-.316-2.96-.585v4.747c4.538.767 10.258.771 13.5.057v-4.612zM13.732 4.904c.35-2.14 1.61-2.874 2.48-2.903.457-.015.85.148 1.113.43.256.275.47.75.402 1.513-.692 7.714.622 13.981.782 14.71a1.087 1.087 0 00.879.842c.483.083 3.93.696 7.353 1.695 1.716.5 3.383 1.086 4.657 1.732.638.324 1.142.645 1.498.953.365.317.499.555.536.697.145.553.145 1.56-.027 2.917-.167 1.317-.48 2.839-.868 4.344a42.804 42.804 0 01-1.297 4.18c-.464 1.243-.884 2.103-1.17 2.476-.032.042-.183.18-.59.35-.384.158-.898.306-1.526.433-1.255.253-2.853.404-4.523.445-1.667.04-3.372-.028-4.834-.2-1.499-.177-2.623-.45-3.208-.752-.552-.286-1.137-.864-1.779-1.725-.614-.825-1.21-1.809-1.845-2.855l-.002-.003-.057-.095c-.643-1.061-1.326-2.181-2.06-3.142-.73-.953-1.575-1.84-2.582-2.362-.542-.28-1.19-.502-1.801-.694-.24-.075-.472-.145-.697-.213l-.006-.002a33.796 33.796 0 01-1.103-.346c-.568-.193-.988-.373-1.256-.554a.79.79 0 01-.2-.172c-.013-.543.165-.885.435-1.137.308-.287.809-.521 1.505-.654 1.4-.268 3.231-.059 4.659.42 1.55.52 2.508 1.258 3.067 1.805.28.274.468.508.59.664l.016.021c.043.056.13.17.201.241.023.023.096.097.204.164l.003.001c.05.032.485.301.994.038.482-.25.532-.73.538-.784v-.003c.014-.12.003-.22-.001-.253-.99-8.455-1.282-17.335-.48-22.222zM1.991 26.586l.004.01-.005-.01z" fill="#fff"></path> </svg> ` const twitchLogo = `` return ` <div data-loaded="false" class="andropov-media andropov-media--rounded andropov-media--bordered andropov-media--has-preview andropov-external-video andropov-external-video--youtube" style="${style}"> <div data-loaded="false" class="andropov-media andropov-media--has-preview andropov-external-video__cover andropov-image andropov-external-video__cover" style="${style}"> <picture> <source srcset="${src}/-/preview/700x/-/format/webp" type="image/webp"> <img src="${src}/-/preview/700x/" alt="" loading="lazy"> </picture> </div> <div class="andropov-external-video__overlay"></div> <div class="andropov-external-video__play-button"> ${serviceName === 'youtube' ? youtubeLogo : serviceName === 'coub' ? coubLogo : twitchLogo} </div> </div> ` } function insertMediaLink({ url, title, description, image: { data: { uuid } } }) { const src = uuid.match(/http/) ? uuid : `https://leonardo.osnova.io/${uuid}` return ` <a class="andropov-link" href="${url}"> <div class="andropov-link__origin"> <div data-loaded="true" class="andropov-media andropov-media--bordered andropov-media--has-preview andropov-link__icon andropov-image andropov-link__icon" style="aspect-ratio: 1 / 1; width: 16px; height: 16px; max-width: none; --background-color: #fff;"> <picture> <source srcset="${src}" type="image/webp"> <img src="${src}" alt="" loading="lazy"> </picture> </div> <div class="andropov-link__hostname">${new URL(url).hostname}</div> </div> <div class="andropov-link__title">${title}</div> <div class="andropov-link__description">${description}</div> </a> ` } function insertMediaNotSupported() { return ` <i>Вложение не поддерживается</i> ` } } catch (error) { console.error(`Ошибка при загрузке комментариев (${url}):`, error) } } function getLinkToCss(regexp, fallback = '') { for (const linkElement of doc.querySelectorAll('link')) { if (linkElement.href.match(regexp)) return linkElement.href } return fallback } async function getCachedStyles(url, key) { try { const link = url.split('/').slice(-1)[0].split('.')[0] const cachedStyles = JSON.parse(localStorage.getItem(key) || '{}') if (cachedStyles[link]) return cachedStyles[link] const styles = await (await fetchData(url)).text() localStorage.setItem(key, JSON.stringify({ [link]: styles })) return styles } catch (error) { console.error(`Ошибка при загрузке стилей (${url}):`, error) } } } catch (error) { console.log('❌ FAILED') console.error(`Ошибка при загрузке страницы (${url}):`, error) } } async function downloadPagesSequentially(links, index = 0) { if (index < links.length) { await downloadPage(links[index], index, links.length) await downloadPagesSequentially(links, index + 1) } } async function downloadPagesInParallel(links, limit = MAX_PARALLEL_DOWNLOADS) { const sem = new Semaphore(limit) // Создаем семафор с ограничением const promises = links.map(async (link, idx) => { await sem.acquire() // Захватываем один слот семафора try { await downloadPage(link, idx, links.length) // Запускаем загрузку страницы } finally { sem.release() // Освобождаем слот семафора после завершения загрузки } }) await Promise.all(promises) // Ожидаем завершения всех загрузок } class Semaphore { constructor(initialCount) { this.count = initialCount this.waitQueue = [] } acquire() { return new Promise(resolve => { if (this.count > 0) { this.count-- resolve() } else { this.waitQueue.push(resolve) } }) } release() { this.count++ const next = this.waitQueue.shift() if (next) next() } } async function fetchData(url, headers) { try { const response = await fetch(url, { headers }) if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After')) || 1 await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)) return await fetchData(url, headers) } if (!response.ok) throw new Error(`status: ${response.status}`) return { json: async () => await response.json(), text: async () => await response.text(), blob: async () => await response.blob(), } } catch (error) { throw new Error(error) } } function createBookmarksHtml(links) { let htmlContent = '<!DOCTYPE NETSCAPE-Bookmark-file-1>\n' htmlContent += '<!-- This is an automatically generated file.\n' htmlContent += 'It will be read and overwritten.\n' htmlContent += 'Do Not Edit! -->\n' htmlContent += '<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">\n' htmlContent += '<TITLE>Bookmarks</TITLE>\n' htmlContent += '<H1>Bookmarks</H1>\n' htmlContent += '<DL><p>\n' const timestamp = new Date().getTime() htmlContent += `<DT><H3 ADD_DATE="${timestamp}" LAST_MODIFIED="${timestamp}">Закладки с DTF</H3>\n` htmlContent += '<DL><p>\n' // Открываем раздел "Закладки" links.forEach(link => { htmlContent += `<DT><A HREF="${link.url}">${link.title}</A>\n` }) htmlContent += '</DL><p>\n' // Закрываем раздел "Закладки" htmlContent += '</DL><p>\n' // Создаем объект Blob с HTML-содержимым const blob = new Blob([htmlContent], { type: 'text/html' }) // Создаем ссылку для скачивания файла const url = URL.createObjectURL(blob) // Создаем ссылку для скачивания HTML-файла с названием "bookmarks.html" const a = document.createElement('a') a.href = url a.download = `bookmarks_${new Date().toLocaleString().slice(0, 10)}.html` // Добавляем ссылку на страницу и автоматически кликаем по ней для скачивания файла document.body.appendChild(a) a.click() // Освобождаем ресурсы URL объекта после скачивания файла URL.revokeObjectURL(url) } let prevPage = window.location.href const pageObserver = new MutationObserver(() => { if (window.location.href !== prevPage) { prevPage = window.location.href setTimeout(setup, DELAY) } }) pageObserver.observe(document.body, { childList: true, subtree: true }) setTimeout(setup, DELAY)

Описание настроек

// НАСТРОЙКИ const THEME = 'auto' // auto, dark, light const SAVE_COMMENTS = true const COMMENT_LIMIT = 10000 const OFFLINE_STYLES = true const OFFLINE_IMAGES = true const BOOKMARKS_HTML = false const USE_DATE_PREFIX = true const LINK_TO_ARCHIVE = true const PARALLEL_DOWNLOADS = true const MAX_PARALLEL_DOWNLOADS = 2 // <= 5 // Время на загрузку DOM const DELAY = 1000 // ========
Не забывайте сохранять изменения

THEME — выбор темы

OFFLINE_STYLES — сохранять стили офлайн

OFFLINE_IMAGES — сохранять картинки офлайн

BOOKMARKS_HTML — предлагать сохранять ссылки bookmarks.html

Как скачать свои закладки

USE_DATE_PREFIX — писать дату добавления в закладки перед файлом

Как скачать свои закладки

LINK_TO_ARCHIVE — искать пост на web.archive.org

Как скачать свои закладки

PARALLEL_DOWNLOAD — включить параллельные загрузки (если не используете OFFLINE_IMAGES, рекомендуется отключить)

MAX_PARRALLEL_DOWNLOADS — количество параллельных загрузок. Максимум 5, если у вас немного постов, иначе получите ошибку 429. Если больше 100, то 3, если больше 200, то 2.

DELAY — если кнопка загрузки не появляется, попробуйте увеличить значение

✨ SAVE_COMMENTS — тут без комментариев. Осторожнее с этой опцией, жрёт много оперативки. В конце-концов вкладка неизбежно вылетит, т.к. способа высвобождать ресурсы пока не знаю. Но, прежде чем это случилось, я смог скачать 700 постов Grangera со всеми комментами, чего большинству должно хватить с головой.

✨ COMMENT_LIMIT — сколько комментариев сохранять

Шаг 2. Добыть токен авторизации

Токен нужен только для закладок

Токен можно получить в DevTools во вкладке Network, открыв запрос me, перейдя на вкладку Headers > Request Header > Jwtauthorization. Если ничего не поняли, посмотрите видео.

Чтобы выделить, быстро кликнуть 3 раза

Токен выдаётся при логине и живёт 30 минут, поэтому его нельзя просто сохранить. Придётся повторять процедуру.

Шаг 3. Начать пользоваться

Как скачать свои закладки

На страницах dtf.ru/bookmarks и dtf.ru/u/ появилась новая кнопка, при нажатии на которую, откроется диалоговое окно, куда нужно вписать параметры:

TOKEN — токен авторизации

COUNT — максимальное количество постов, которые нужно сохранить

OFFSET — количество постов от начала, которые нужно пропустить

Как скачать свои закладки

Ответ от сервера займёт какое-то время

Ограничения

Не работают галлерея и зум картинок

Совет

В большинстве браузеров открытые вкладки можно превратить в закладки. Так что вы можете даже сделать быстрый доступ к файлам.

Как скачать свои закладки
Как скачать свои закладки

Так выглядит просмотр статьи в режиме офлайн

На месте цветных прямоугольников находятся видео
Полезно?
Да
Нет
Результат
8383
58 комментариев

Комментарий недоступен

22
Ответить

пусть сначала роскомнадзор проверит твой код
вдруг он вредоносный и ты украдешь деньги с карты

4
Ответить

Так в этом весь смысл

10
Ответить

Краду деньги со своей карты каждый день. Пока никто не просёк

2
Ответить

Закладки ищешь? 🤔

5
Ответить
2
Ответить

А чего с рекламой?

1
Ответить