Initial commit: read social posts back without the app
antisocial is the other half of a StopTheMadness redirect rule. Links to X, Threads, Instagram, TikTok and Bluesky get rewritten to /<prefix>/<original path>, and this resolves the post and shows the media and the words, with a badge saying where it came from and a button to copy the original URL. Every request drives a real headless Chromium, logged out, from a residential IP. One code path, and it survives markup changes better than parsing from the outside would. Extraction is layered, most structured first: the platform's own API response caught in flight, then an inline payload, then the rendered DOM, then Open Graph tags. Media is never linked straight at a CDN. Instagram and TikTok reject requests without a matching Referer and cookies, and proxying keeps the viewer's browser from talking to the platform at all. Range is forwarded so the native video scrubber can seek. HLS is the exception, since proxying it would mean rewriting playlists. TikTok sometimes answers with a slider puzzle. Rather than reporting that as a failure, the page is parked and the viewer is handed the puzzle: screenshots stream out, pointer events are replayed back. Solving it leaves the cookie in the shared browser context, so the retry is an ordinary request. A failed resolve is never a blank error page. The card carries the platform, the original URL and the copy button, so a broken adapter still leaves the link one tap away. Verified end to end against real shared links on all five platforms, in the container, including multi-image carousels, reels, TikTok short links and photo posts. 49 tests run the adapters against captured payloads with no network. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
This commit is contained in:
+241
@@ -0,0 +1,241 @@
|
||||
// Progressive enhancement only. Without this file the page still shows the
|
||||
// media in a swipeable rail and the original URL as selectable text.
|
||||
|
||||
const VIEW_KEY = 'antisocial:view';
|
||||
|
||||
function setupCopy() {
|
||||
for (const button of document.querySelectorAll('.copy')) {
|
||||
button.addEventListener('click', async () => {
|
||||
const url = button.dataset.url;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
// No clipboard permission, or an insecure context. Select the URL
|
||||
// instead so one keystroke finishes the job.
|
||||
const code = button.closest('.original')?.querySelector('code');
|
||||
if (code) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(code);
|
||||
const selection = getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
button.dataset.copied = 'true';
|
||||
button.querySelector('.copy__idle')?.setAttribute('hidden', '');
|
||||
button.querySelector('.copy__done')?.removeAttribute('hidden');
|
||||
setTimeout(() => {
|
||||
delete button.dataset.copied;
|
||||
button.querySelector('.copy__idle')?.removeAttribute('hidden');
|
||||
button.querySelector('.copy__done')?.setAttribute('hidden', '');
|
||||
}, 1600);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupMedia() {
|
||||
const media = document.querySelector('.media');
|
||||
if (!media) return;
|
||||
|
||||
const rail = media.querySelector('.media__rail');
|
||||
const items = [...media.querySelectorAll('.item')];
|
||||
const at = media.querySelector('.media__at');
|
||||
const buttons = [...media.querySelectorAll('.media__view')];
|
||||
if (!rail || items.length < 2) return;
|
||||
|
||||
function applyView(view, remember) {
|
||||
media.dataset.view = view;
|
||||
for (const button of buttons) {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.view === view));
|
||||
}
|
||||
if (remember) {
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, view);
|
||||
} catch {
|
||||
// Private browsing. The choice just won't survive the page.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(VIEW_KEY);
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
applyView(stored === 'grid' ? 'grid' : 'swipe', false);
|
||||
|
||||
for (const button of buttons) {
|
||||
button.addEventListener('click', () => applyView(button.dataset.view, true));
|
||||
}
|
||||
|
||||
// Tapping a thumbnail goes back to the swiper, at that image.
|
||||
for (const item of items) {
|
||||
item.addEventListener('click', (event) => {
|
||||
if (media.dataset.view !== 'grid') return;
|
||||
if (event.target.closest('video')) return;
|
||||
applyView('swipe', true);
|
||||
item.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the counter honest as the rail scrolls.
|
||||
if (at) {
|
||||
let frame = 0;
|
||||
rail.addEventListener('scroll', () => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
const index = Math.round(rail.scrollLeft / rail.clientWidth);
|
||||
at.textContent = String(Math.min(items.length, Math.max(1, index + 1)));
|
||||
});
|
||||
}, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
setupCopy();
|
||||
setupMedia();
|
||||
|
||||
// --- Verification puzzles -------------------------------------------------
|
||||
//
|
||||
// The puzzle is rendered in the browser on the server, not this one. So the
|
||||
// page shows a live screenshot of it and forwards pointer events back to be
|
||||
// replayed. Solving it leaves the cookie in that browser, and the post then
|
||||
// loads normally.
|
||||
|
||||
function setupChallenge() {
|
||||
const article = document.querySelector('[data-challenge]');
|
||||
if (!article) return;
|
||||
|
||||
const id = article.dataset.challenge;
|
||||
const frame = document.getElementById('frame');
|
||||
const status = document.getElementById('status');
|
||||
const stage = document.getElementById('stage');
|
||||
const giveUp = document.getElementById('give-up');
|
||||
|
||||
let box = parseBox(stage.dataset.box);
|
||||
let dragging = false;
|
||||
let lastSent = 0;
|
||||
let stopped = false;
|
||||
|
||||
function parseBox(value) {
|
||||
const parts = (value || '').split(',').map(Number);
|
||||
return parts.length === 4 && parts.every(Number.isFinite)
|
||||
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
||||
: null;
|
||||
}
|
||||
|
||||
// Self-paced: the next frame is only asked for once the last one painted,
|
||||
// so a slow screenshot slows the stream instead of queueing behind itself.
|
||||
function pump() {
|
||||
if (stopped) return;
|
||||
frame.src = `/challenge/${id}/frame?t=${Date.now()}`;
|
||||
}
|
||||
frame.addEventListener('load', () => {
|
||||
status.hidden = true;
|
||||
setTimeout(pump, dragging ? 60 : 220);
|
||||
});
|
||||
frame.addEventListener('error', () => {
|
||||
if (!stopped) setTimeout(pump, 1000);
|
||||
});
|
||||
pump();
|
||||
|
||||
function toPageCoords(event) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const fx = (event.clientX - rect.left) / rect.width;
|
||||
const fy = (event.clientY - rect.top) / rect.height;
|
||||
// Without a clip box the screenshot is the whole viewport, whose size
|
||||
// the server knows; fall back to the image's own pixel dimensions.
|
||||
const region = box ?? { x: 0, y: 0, width: frame.naturalWidth / 2, height: frame.naturalHeight / 2 };
|
||||
return { x: region.x + fx * region.width, y: region.y + fy * region.height };
|
||||
}
|
||||
|
||||
async function send(type, point) {
|
||||
try {
|
||||
const response = await fetch(`/challenge/${id}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type, x: point.x, y: point.y }),
|
||||
});
|
||||
if (response.status === 410) finish();
|
||||
} catch {
|
||||
// A dropped event during a drag is survivable; the next one follows.
|
||||
}
|
||||
}
|
||||
|
||||
frame.addEventListener('pointerdown', (event) => {
|
||||
const point = toPageCoords(event);
|
||||
if (!point) return;
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
frame.setPointerCapture(event.pointerId);
|
||||
void send('down', point);
|
||||
});
|
||||
|
||||
frame.addEventListener('pointermove', (event) => {
|
||||
if (!dragging) return;
|
||||
const now = Date.now();
|
||||
if (now - lastSent < 25) return;
|
||||
lastSent = now;
|
||||
const point = toPageCoords(event);
|
||||
if (point) void send('move', point);
|
||||
});
|
||||
|
||||
for (const type of ['pointerup', 'pointercancel']) {
|
||||
frame.addEventListener(type, async (event) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const point = toPageCoords(event);
|
||||
if (point) await send('up', point);
|
||||
status.hidden = false;
|
||||
status.textContent = 'Checking…';
|
||||
void poll();
|
||||
});
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
for (let attempt = 0; attempt < 12 && !stopped; attempt += 1) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
const response = await fetch(`/challenge/${id}/state`);
|
||||
if (response.status === 410) return finish();
|
||||
const state = await response.json();
|
||||
if (state.box) box = state.box;
|
||||
if (state.solved) return finish();
|
||||
} catch {
|
||||
// Keep trying; the page may be mid-navigation.
|
||||
}
|
||||
}
|
||||
status.textContent = 'Still there — have another go.';
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
status.hidden = false;
|
||||
status.textContent = 'Got it. Loading the post…';
|
||||
try {
|
||||
const response = await fetch(`/challenge/${id}/done`, { method: 'POST' });
|
||||
const { returnTo } = await response.json();
|
||||
location.href = returnTo || '/';
|
||||
} catch {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
giveUp?.addEventListener('click', () => {
|
||||
stopped = true;
|
||||
void fetch(`/challenge/${id}/done`, { method: 'POST' }).finally(() => {
|
||||
article.querySelector('.original__url')?.scrollIntoView({ block: 'center' });
|
||||
status.textContent = 'Gave up. The original link is below.';
|
||||
status.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupChallenge();
|
||||
Reference in New Issue
Block a user