mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-26 17:07:47 +02:00
356 lines
11 KiB
Dart
356 lines
11 KiB
Dart
/// Best-effort Instagram media downloader UI.
|
|
///
|
|
/// The script only exposes URLs already rendered in the WebView. It cannot
|
|
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
|
|
/// feed posts, reels, profile avatars, and DM visual/video messages.
|
|
const String kVideoDownloadJS = r'''
|
|
(function() {
|
|
'use strict';
|
|
|
|
if (window.__fgMediaDownloadRunning) return;
|
|
window.__fgMediaDownloadRunning = true;
|
|
|
|
const BTN_ATTR = 'data-fg-download-btn';
|
|
const URL_ATTR = 'data-fg-download-url';
|
|
const TYPE_ATTR = 'data-fg-download-type';
|
|
const MAX_PER_PASS = 60;
|
|
|
|
function text(value) {
|
|
try { return (value || '').toString(); } catch (_) { return ''; }
|
|
}
|
|
|
|
function isHttp(value) {
|
|
const s = text(value);
|
|
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
|
|
}
|
|
|
|
function cleanUrl(value) {
|
|
const s = text(value).trim();
|
|
if (!isHttp(s)) return null;
|
|
return s.replace(/&/g, '&');
|
|
}
|
|
|
|
function bestFromSrcset(srcset) {
|
|
const raw = text(srcset);
|
|
if (!raw) return null;
|
|
let best = null;
|
|
let bestScore = -1;
|
|
raw.split(',').forEach(function(part) {
|
|
const bits = part.trim().split(/\s+/);
|
|
const url = cleanUrl(bits[0]);
|
|
if (!url) return;
|
|
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
|
|
if (score >= bestScore) {
|
|
bestScore = score;
|
|
best = url;
|
|
}
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function backgroundUrl(el) {
|
|
try {
|
|
const bg = window.getComputedStyle(el).backgroundImage || '';
|
|
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
|
return match ? cleanUrl(match[1]) : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function urlFromJsonishAttribute(el) {
|
|
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
|
|
for (let i = 0; i < attrs.length; i++) {
|
|
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
|
|
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
|
|
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mediaUrl(el) {
|
|
if (!el) return null;
|
|
const tag = text(el.tagName).toLowerCase();
|
|
if (tag === 'video') {
|
|
return cleanUrl(el.currentSrc || el.src) ||
|
|
cleanUrl(el.getAttribute('src')) ||
|
|
cleanUrl(el.getAttribute('poster')) ||
|
|
firstSource(el);
|
|
}
|
|
if (tag === 'img') {
|
|
return cleanUrl(el.currentSrc || el.src) ||
|
|
bestFromSrcset(el.getAttribute('srcset')) ||
|
|
cleanUrl(el.getAttribute('src'));
|
|
}
|
|
return backgroundUrl(el) || urlFromJsonishAttribute(el);
|
|
}
|
|
|
|
function firstSource(video) {
|
|
try {
|
|
const sources = video.querySelectorAll('source');
|
|
for (let i = 0; i < sources.length; i++) {
|
|
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
|
|
if (url) return url;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
function typeFrom(el, url) {
|
|
const tag = text(el && el.tagName).toLowerCase();
|
|
const u = text(url).toLowerCase();
|
|
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
|
|
return 'video';
|
|
}
|
|
return 'photo';
|
|
}
|
|
|
|
function looksLikeAvatar(el) {
|
|
try {
|
|
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
|
|
if (!img) return false;
|
|
const alt = text(img.getAttribute('alt')).toLowerCase();
|
|
const r = img.getBoundingClientRect();
|
|
const rounded =
|
|
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
|
|
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
|
|
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function mediaScore(item) {
|
|
try {
|
|
const r = item.el.getBoundingClientRect();
|
|
let score = Math.max(0, r.width) * Math.max(0, r.height);
|
|
if (item.type === 'video') score += 10000000;
|
|
if (looksLikeAvatar(item.el)) score -= 10000000;
|
|
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
|
|
return score;
|
|
} catch (_) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function filename(type) {
|
|
const ext = type === 'video' ? 'mp4' : 'jpg';
|
|
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
|
|
}
|
|
|
|
function inView(el) {
|
|
try {
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function icon() {
|
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
|
|
}
|
|
|
|
function sendDownload(url, type) {
|
|
try {
|
|
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
|
|
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
|
|
type: type,
|
|
url: url,
|
|
filename: filename(type),
|
|
}));
|
|
} catch (_) {}
|
|
}
|
|
|
|
function makeButton(url, type, mode) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.setAttribute(BTN_ATTR, '1');
|
|
btn.setAttribute(URL_ATTR, url);
|
|
btn.setAttribute(TYPE_ATTR, type);
|
|
btn.setAttribute('aria-label', 'Download media');
|
|
btn.innerHTML = icon();
|
|
btn.style.cssText = [
|
|
'position:absolute',
|
|
'z-index:999',
|
|
'width:34px',
|
|
'height:34px',
|
|
'border-radius:10px',
|
|
'border:1px solid rgba(255,255,255,.18)',
|
|
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
|
|
'color:rgba(255,255,255,.94)',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:center',
|
|
'padding:0',
|
|
'cursor:pointer',
|
|
'pointer-events:auto',
|
|
'backdrop-filter:blur(8px)',
|
|
'-webkit-backdrop-filter:blur(8px)',
|
|
].join(';');
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
|
|
}, true);
|
|
return btn;
|
|
}
|
|
|
|
function ensureRelative(container) {
|
|
try {
|
|
const pos = window.getComputedStyle(container).position;
|
|
if (!pos || pos === 'static') container.style.position = 'relative';
|
|
} catch (_) {}
|
|
}
|
|
|
|
function placeNearSave(article, url, type) {
|
|
const ref = article.querySelector([
|
|
'button[aria-label*="Save" i]',
|
|
'button[aria-label*="Bookmark" i]',
|
|
'svg[aria-label*="Save" i]',
|
|
'svg[aria-label*="Bookmark" i]',
|
|
'a[href*="/save"]',
|
|
].join(','));
|
|
if (!ref) return false;
|
|
|
|
const target = ref.closest('button,a,div') || ref;
|
|
const bar = target.parentElement || article;
|
|
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
|
|
|
const btn = makeButton(url, type, 'inline');
|
|
btn.style.position = 'relative';
|
|
btn.style.inset = 'auto';
|
|
btn.style.marginLeft = '8px';
|
|
btn.style.color = 'currentColor';
|
|
btn.style.border = '0';
|
|
btn.style.backdropFilter = 'none';
|
|
btn.style.webkitBackdropFilter = 'none';
|
|
try {
|
|
target.insertAdjacentElement('afterend', btn);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function placeOverlay(container, url, type, where) {
|
|
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
|
ensureRelative(container);
|
|
const btn = makeButton(url, type, 'overlay');
|
|
if (where === 'reel') {
|
|
btn.style.top = '12px';
|
|
btn.style.right = '12px';
|
|
} else if (where === 'profile') {
|
|
btn.style.top = '8px';
|
|
btn.style.right = '8px';
|
|
} else {
|
|
btn.style.right = '10px';
|
|
btn.style.bottom = '10px';
|
|
}
|
|
container.appendChild(btn);
|
|
return true;
|
|
}
|
|
|
|
function visibleMedia(root) {
|
|
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
|
|
.filter(inView)
|
|
.map(function(el) {
|
|
const url = mediaUrl(el);
|
|
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function handleFeed() {
|
|
let added = 0;
|
|
document.querySelectorAll('article').forEach(function(article) {
|
|
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
|
|
const media = visibleMedia(article)
|
|
.filter(function(item) { return !looksLikeAvatar(item.el); })
|
|
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
|
|
if (!media) return;
|
|
if (placeNearSave(article, media.url, media.type) ||
|
|
placeOverlay(article, media.url, media.type, 'feed')) {
|
|
added++;
|
|
}
|
|
});
|
|
return added;
|
|
}
|
|
|
|
function handleReels() {
|
|
let added = 0;
|
|
visibleMedia(document).forEach(function(media) {
|
|
if (added >= MAX_PER_PASS) return;
|
|
const container =
|
|
media.el.closest('[class*="ReelsVideoPlayer"]') ||
|
|
media.el.closest('article') ||
|
|
media.el.closest('[role="presentation"]') ||
|
|
media.el.parentElement;
|
|
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
|
|
});
|
|
return added;
|
|
}
|
|
|
|
function handleDirect() {
|
|
let added = 0;
|
|
visibleMedia(document).forEach(function(media) {
|
|
if (added >= MAX_PER_PASS) return;
|
|
const bubble =
|
|
media.el.closest('[role="button"]') ||
|
|
media.el.closest('div[style*="max-width"]') ||
|
|
media.el.closest('article') ||
|
|
media.el.parentElement;
|
|
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
|
|
});
|
|
return added;
|
|
}
|
|
|
|
function handleProfile() {
|
|
let added = 0;
|
|
const path = window.location.pathname || '/';
|
|
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
|
|
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
|
|
if (added >= 4 || !inView(img)) return;
|
|
const url = mediaUrl(img);
|
|
if (!url) return;
|
|
const r = img.getBoundingClientRect();
|
|
if (r.width < 56 && r.height < 56) return;
|
|
const container = img.closest('div') || img.parentElement;
|
|
if (placeOverlay(container, url, 'photo', 'profile')) added++;
|
|
});
|
|
return added;
|
|
}
|
|
|
|
function pass() {
|
|
try {
|
|
const path = window.location.pathname || '/';
|
|
if (path.indexOf('/direct') === 0) {
|
|
handleDirect();
|
|
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
|
|
handleReels();
|
|
} else {
|
|
handleFeed();
|
|
handleProfile();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
let timer = null;
|
|
function schedule() {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(pass, 220);
|
|
}
|
|
|
|
new MutationObserver(schedule).observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['src', 'srcset', 'style'],
|
|
});
|
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
window.addEventListener('resize', schedule, { passive: true });
|
|
window.addEventListener('focus', schedule, { passive: true });
|
|
pass();
|
|
})();
|
|
''';
|