mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
feat: server-side t.co resolver + frontend augmentation
Server (routes.rs): POST /api/util/resolve-tco — unauthenticated, accepts JSON array of https://t.co/<alphanumeric> URLs only (strict regex validation, no SSRF via input), capped at 50 per batch, 3 s timeout, redirect(Policy::none) so the server only ever touches t.co itself. HEAD first, GET fallback if HEAD returns no Location. Location sanitized to http/https only — javascript:/data:/etc. fall back to the original t.co. api.js: resolveTcoUrls(urls) — project-convention wrapper for the new endpoint. Returns {} on failure (callers degrade gracefully to bare t.co links). TweetPreview.jsx: After tweet data loads, per-tweet range-based coverage detection: builds covered [start,end) from existing entity fromIndex/toIndex or indices fallback, then finds regex matches whose span is NOT covered. Resolves unique uncovered t.co URLs via resolveTcoUrls(), synthesises one entity per occurrence (with exact fromIndex/toIndex so normalizeUrlAnn gets correct bounds even for duplicate t.co URLs in the same tweet). Augments entities.urls before setTweets() so all rendering paths see expanded URLs.
This commit is contained in:
parent
a06c541605
commit
1589eb68b5
9 changed files with 185 additions and 50 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { fetchEntryArtifacts } from '../api';
|
||||
import { fetchEntryArtifacts, resolveTcoUrls } from '../api';
|
||||
|
||||
// ── Date helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1148,7 +1148,57 @@ export default function TweetPreview({ archiveId, entryUid, artifacts, entityKin
|
|||
let cancelled = false;
|
||||
|
||||
fetchEntryArtifacts(archiveId, entryUid, tweetArtifacts.map(a => a.index))
|
||||
.then(data => { if (!cancelled) setTweets(data); })
|
||||
.then(async data => {
|
||||
if (cancelled) return;
|
||||
|
||||
const tcoRe = /https?:\/\/t\.co\/[A-Za-z0-9]+/g;
|
||||
|
||||
// For each tweet, build the set of [start,end) spans already covered by
|
||||
// existing URL entities. A regex match is only "entity-less" if its span
|
||||
// is not covered — avoids skipping real duplicate occurrences.
|
||||
const coveredRanges = tweet => {
|
||||
const ft = tweet.full_text || '';
|
||||
return (tweet.entities?.urls || []).map(u => {
|
||||
if (u.fromIndex != null && u.toIndex != null) return [u.fromIndex, u.toIndex];
|
||||
if (u.indices?.length === 2) return [u.indices[0], u.indices[1]];
|
||||
if (u.url) { const i = ft.indexOf(u.url); if (i !== -1) return [i, i + u.url.length]; }
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
const isCovered = (ranges, s, e) => ranges.some(([cs, ce]) => cs <= s && ce >= e);
|
||||
|
||||
const uniqueTco = new Set();
|
||||
for (const t of data) {
|
||||
const ft = t.full_text || '';
|
||||
const ranges = coveredRanges(t);
|
||||
let m; tcoRe.lastIndex = 0;
|
||||
while ((m = tcoRe.exec(ft)) !== null) {
|
||||
if (!isCovered(ranges, m.index, m.index + m[0].length)) uniqueTco.add(m[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = uniqueTco.size > 0 ? await resolveTcoUrls([...uniqueTco]).catch(() => ({})) : {};
|
||||
|
||||
const augmented = data.map(t => {
|
||||
const ft = t.full_text || '';
|
||||
const ranges = coveredRanges(t);
|
||||
const synth = [];
|
||||
let m; tcoRe.lastIndex = 0;
|
||||
while ((m = tcoRe.exec(ft)) !== null) {
|
||||
const tco = m[0]; const exp = resolved[tco];
|
||||
if (exp && exp !== tco && !isCovered(ranges, m.index, m.index + tco.length)) {
|
||||
synth.push({
|
||||
url: tco, expanded_url: exp,
|
||||
display_url: (() => { try { const u = new URL(exp); return u.hostname + (u.pathname.length > 1 ? '/…' : ''); } catch (_) { return exp; } })(),
|
||||
fromIndex: m.index, toIndex: m.index + tco.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (synth.length === 0) return t;
|
||||
return { ...t, entities: { ...(t.entities || {}), urls: [...(t.entities?.urls || []), ...synth] } };
|
||||
});
|
||||
if (!cancelled) setTweets(augmented);
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e.message || 'Failed to load tweet.'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue