// ==UserScript==
// @name Social Post Tools
// @namespace social-post-tools
// @version 4.4.0
// @description Simple post sharing and AI capture for X/Threads, with optional advanced link builders, archive tools, Android sharing, and Telegram.
// @match https://share-tools.mythic3011.com/capture-handoff.html*
// @homepageURL https://share-tools.mythic3011.com/
// @supportURL https://github.com/mythic3011/social-post-tools/issues
// @downloadURL https://raw.githubusercontent.com/mythic3011/social-post-tools/dist/social-post-tools.user.js
// @updateURL https://raw.githubusercontent.com/mythic3011/social-post-tools/dist/social-post-tools.meta.js
// @match https://x.com/*
// @match https://twitter.com/*
// @match https://mobile.twitter.com/*
// @match https://www.threads.com/*
// @match https://threads.com/*
// @match https://www.threads.net/*
// @match https://threads.net/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant unsafeWindow
// @connect pbs.twimg.com
// @connect *.twimg.com
// @connect *.fbcdn.net
// @connect *.cdninstagram.com
// ==/UserScript==
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
root.SocialPostCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const VERSION = '1.5.0';
const MAX_BUILDER_URL_CHARS = 8192;
const MAX_CUSTOM_BUILDERS = 32;
const PLATFORMS = Object.freeze({
x: Object.freeze({
id: 'x',
name: 'X',
hosts: Object.freeze(['x.com', 'twitter.com', 'mobile.twitter.com']),
canonicalOrigin: 'https://x.com',
}),
threads: Object.freeze({
id: 'threads',
name: 'Threads',
hosts: Object.freeze(['threads.com', 'www.threads.com', 'threads.net', 'www.threads.net']),
canonicalOrigin: 'https://www.threads.com',
}),
});
const DEFAULT_BUILDERS = Object.freeze({
x: 'fixupx',
threads: 'vxthreads',
});
const BUILTIN_BUILDERS = Object.freeze([
Object.freeze({ id: 'fixupx', name: 'FixupX', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://fixupx.com', builtin: true, group: 'Embed fixer', capability: 'embed', status: 'recommended', detail: 'Chat-friendly X previews', retired: false }),
Object.freeze({ id: 'fixvx', name: 'FixVX', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://fixvx.com', builtin: true, group: 'Embed fixer', capability: 'embed', status: 'available', detail: 'Alternative X embed fixer', retired: false }),
Object.freeze({ id: 'xcancel', name: 'XCancel', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://xcancel.com', builtin: true, group: 'Reader', capability: 'reader', status: 'available', detail: 'Alternative X reader', retired: false }),
Object.freeze({ id: 'vxthreads', name: 'vxThreads', platforms: ['threads'], type: 'replace-origin', baseUrl: 'https://vxthreads.net', builtin: true, group: 'Embed fixer', capability: 'embed', status: 'available', detail: 'Chat-friendly Threads previews', retired: false }),
Object.freeze({ id: 'nitter-net', name: 'Nitter.net', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://nitter.net', builtin: true, group: 'Legacy reader', capability: 'reader', status: 'retired', detail: 'Legacy compatibility only', retired: true }),
Object.freeze({ id: 'nitter-catsarch', name: 'Nitter · catsarch.com', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://nitter.catsarch.com', builtin: true, group: 'Legacy reader', capability: 'reader', status: 'retired', detail: 'Legacy compatibility only', retired: true }),
Object.freeze({ id: 'nitter-privacyredirect', name: 'Nitter · privacyredirect.com', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://nitter.privacyredirect.com', builtin: true, group: 'Legacy reader', capability: 'reader', status: 'retired', detail: 'Legacy compatibility only', retired: true }),
Object.freeze({ id: 'nitter-tiekoetter', name: 'Nitter · tiekoetter.com', platforms: ['x'], type: 'replace-origin', baseUrl: 'https://nitter.tiekoetter.com', builtin: true, group: 'Legacy reader', capability: 'reader', status: 'retired', detail: 'Legacy compatibility only', retired: true }),
]);
function parseUrl(raw, base = 'https://example.invalid/') {
try { return new URL(raw, base); } catch { return null; }
}
function cleanText(value) {
return String(value || '')
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, '')
.replace(/\r/g, '')
.split('\n')
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
.filter(Boolean)
.join('\n')
.trim();
}
function uniqueValues(values) {
const seen = new Set();
return (values || []).filter((value) => {
const text = String(value || '');
const key = text.toLocaleLowerCase();
if (!text || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function boundedString(value, max) {
return String(value ?? '').slice(0, max);
}
function stripTrailingSlash(raw) {
return String(raw || '').replace(/\/+$/, '');
}
function platformById(platform) {
if (!platform) return null;
const id = typeof platform === 'string' ? platform : platform.id;
return PLATFORMS[id] || null;
}
function platformForUrl(raw, base) {
const url = parseUrl(raw, base);
if (!url) return null;
const host = url.hostname.toLowerCase();
return Object.values(PLATFORMS).find((platform) => platform.hosts.includes(host)) || null;
}
function defaultBuilderId(platform) {
const p = platformById(platform);
return p ? DEFAULT_BUILDERS[p.id] || null : null;
}
function canonicalize(platform, rawUrl, base) {
const p = platformById(platform) || platformForUrl(rawUrl, base);
const url = parseUrl(rawUrl, base);
if (!p || !url || !p.hosts.includes(url.hostname.toLowerCase())) return null;
if (p.id === 'x') {
const match = /^\/([^/]+)\/status\/(\d+)(?:\/.*)?$/.exec(url.pathname);
if (!match) return null;
return `${p.canonicalOrigin}/${match[1]}/status/${match[2]}`;
}
if (p.id === 'threads') {
let match = /^\/(?:@|%40)([^/]+)\/post\/([A-Za-z0-9_-]+)(?:\/.*)?$/i.exec(url.pathname);
if (match) return `${p.canonicalOrigin}/@${match[1]}/post/${match[2]}`;
match = /^\/t\/([A-Za-z0-9_-]+)(?:\/.*)?$/i.exec(url.pathname);
if (match) return `${p.canonicalOrigin}/t/${match[1]}`;
}
return null;
}
function postParts(platform, canonicalUrl) {
const p = platformById(platform) || platformForUrl(canonicalUrl);
const url = parseUrl(canonicalUrl);
if (!p || !url) return { author: null, postId: null };
if (p.id === 'x') {
const match = /^\/([^/]+)\/status\/(\d+)$/.exec(url.pathname);
return match ? { author: decodeURIComponent(match[1]), postId: match[2] } : { author: null, postId: null };
}
if (p.id === 'threads') {
let match = /^\/@([^/]+)\/post\/([A-Za-z0-9_-]+)$/i.exec(url.pathname);
if (match) return { author: decodeURIComponent(match[1]), postId: match[2] };
match = /^\/t\/([A-Za-z0-9_-]+)$/i.exec(url.pathname);
if (match) return { author: null, postId: match[1] };
}
return { author: null, postId: null };
}
function hasUrlCredentials(url) {
return Boolean(url?.username || url?.password);
}
function isLoopbackHost(hostname) {
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
return host === 'localhost'
|| host.endsWith('.localhost')
|| host === '::1'
|| /^127(?:\.\d{1,3}){3}$/.test(host);
}
function customUrlAllowed(url, allowInsecureHttp = false) {
if (!url || hasUrlCredentials(url)) return false;
if (url.protocol === 'https:') return true;
if (url.protocol !== 'http:') return false;
return isLoopbackHost(url.hostname) || Boolean(allowInsecureHttp);
}
function templateProbeUrl(template) {
const probe = String(template || '').replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, 'x');
return parseUrl(probe, 'https://example.invalid/');
}
function normalizeCustomBuilder(raw, {
allowInsecureHttp = false,
maxNameChars = 80,
maxIdChars = 96,
} = {}) {
if (!raw || typeof raw !== 'object') return null;
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const id = boundedString(String(raw.id || `custom-${random}`), maxIdChars).replace(/[^a-zA-Z0-9_-]/g, '-');
const name = cleanText(raw.name).slice(0, maxNameChars);
const platforms = uniqueValues((raw.platforms || []).filter((platform) => Boolean(PLATFORMS[platform])));
const type = ['replace-origin', 'template', 'structured'].includes(raw.type) ? raw.type : 'replace-origin';
const capability = ['embed', 'reader', 'archive', 'custom'].includes(raw.capability) ? raw.capability : 'custom';
if (!name || !platforms.length || !id) return null;
const builder = { id, name, platforms, type, capability, status: 'custom', retired: false, builtin: false };
if (type === 'replace-origin') {
const base = parseUrl(boundedString(raw.baseUrl, 2048));
if (!customUrlAllowed(base, allowInsecureHttp)) return null;
base.search = '';
base.hash = '';
builder.baseUrl = stripTrailingSlash(base.href);
} else if (type === 'template') {
const template = boundedString(raw.template, 4096).trim();
const authority = template.match(/^https?:\/\/([^/]+)/i)?.[1] || '';
const probe = templateProbeUrl(template);
if (!template || !authority || /\{/.test(authority) || !customUrlAllowed(probe, allowInsecureHttp)) return null;
builder.template = template;
} else {
const base = parseUrl(boundedString(raw.baseUrl, 2048));
if (!customUrlAllowed(base, allowInsecureHttp)) return null;
base.search = '';
base.hash = '';
builder.baseUrl = stripTrailingSlash(base.href);
builder.pathTemplate = boundedString(raw.pathTemplate || '{path}', 2048);
const query = {};
if (raw.query && typeof raw.query === 'object' && !Array.isArray(raw.query)) {
for (const [key, value] of Object.entries(raw.query).slice(0, 32)) {
const safeKey = boundedString(key, 128);
if (!safeKey) continue;
query[safeKey] = boundedString(value, 2048);
}
}
builder.query = query;
}
return builder;
}
function builderRegistry(customBuilders = [], { allowInsecureHttp = false, includeRetired = true } = {}) {
const used = new Set(BUILTIN_BUILDERS.map((builder) => builder.id));
const normalized = [];
for (const raw of (Array.isArray(customBuilders) ? customBuilders : []).slice(0, MAX_CUSTOM_BUILDERS)) {
const builder = normalizeCustomBuilder(raw, { allowInsecureHttp });
if (!builder || used.has(builder.id)) continue;
used.add(builder.id);
normalized.push(builder);
}
const registry = [...BUILTIN_BUILDERS, ...normalized];
return includeRetired ? registry : registry.filter((builder) => !builder.retired);
}
function compatibleBuilders(platform, customBuilders = [], options = {}) {
const p = platformById(platform);
if (!p) return [];
return builderRegistry(customBuilders, { ...options, includeRetired: false })
.filter((builder) => builder.platforms.includes(p.id));
}
function builderById(id, customBuilders = [], options = {}) {
return builderRegistry(customBuilders, { ...options, includeRetired: true })
.find((builder) => builder.id === id) || null;
}
function builderVars(platform, canonicalUrl) {
const p = platformById(platform) || platformForUrl(canonicalUrl);
const url = parseUrl(canonicalUrl);
if (!p || !url) return null;
const parts = postParts(p, canonicalUrl);
return {
url: canonicalUrl,
encodedUrl: encodeURIComponent(canonicalUrl),
origin: url.origin,
host: url.hostname,
path: url.pathname,
author: parts.author || '',
postId: parts.postId || '',
platform: p.id,
};
}
function applyTemplate(template, vars, { encode = false } = {}) {
return String(template || '').replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (full, key) => {
if (!(key in vars)) return full;
const value = String(vars[key] ?? '');
return encode ? encodeURIComponent(value) : value;
});
}
function finalizeBuiltUrl(builder, raw, { allowInsecureHttp = false, maxUrlChars = MAX_BUILDER_URL_CHARS } = {}) {
if (!raw || String(raw).length > maxUrlChars) return null;
const url = parseUrl(raw);
if (!url || hasUrlCredentials(url)) return null;
if (builder?.builtin) return url.protocol === 'https:' ? url.href : null;
return customUrlAllowed(url, allowInsecureHttp) ? url.href : null;
}
function buildUrl(builder, platform, canonicalUrl, options = {}) {
const p = platformById(platform) || platformForUrl(canonicalUrl);
if (!builder || !p || !builder.platforms?.includes(p.id)) return null;
const source = parseUrl(canonicalUrl);
const vars = builderVars(p, canonicalUrl);
if (!source || !vars) return null;
try {
if (builder.type === 'replace-origin') {
const target = parseUrl(builder.baseUrl);
if (!target) return null;
target.pathname = target.pathname.replace(/\/$/, '') + source.pathname;
target.search = '';
target.hash = '';
return finalizeBuiltUrl(builder, target.href, options);
}
if (builder.type === 'template') {
return finalizeBuiltUrl(builder, applyTemplate(builder.template, vars), options);
}
if (builder.type === 'structured') {
const target = parseUrl(builder.baseUrl);
if (!target) return null;
const path = applyTemplate(builder.pathTemplate || '{path}', vars);
target.pathname = target.pathname.replace(/\/$/, '') + (path.startsWith('/') ? path : `/${path}`);
target.search = '';
for (const [key, valueTemplate] of Object.entries(builder.query || {})) {
target.searchParams.set(key, applyTemplate(String(valueTemplate), vars));
}
target.hash = '';
return finalizeBuiltUrl(builder, target.href, options);
}
} catch {}
return null;
}
function selectBuilder(platform, builderId, customBuilders = [], options = {}) {
const compatible = compatibleBuilders(platform, customBuilders, options);
const requested = compatible.find((builder) => builder.id === builderId);
if (requested) return requested;
const preferred = defaultBuilderId(platform);
return compatible.find((builder) => builder.id === preferred) || compatible[0] || null;
}
function normalizeBuilderId(platform, builderId, customBuilders = [], options = {}) {
return selectBuilder(platform, builderId, customBuilders, options)?.id || defaultBuilderId(platform);
}
function transformedUrl(platform, canonicalUrl, builderId, customBuilders = [], options = {}) {
const builder = selectBuilder(platform, builderId, customBuilders, options);
return buildUrl(builder, platform, canonicalUrl, options);
}
function httpUrlsInText(text) {
const input = String(text || '');
const matches = input.match(/https?:\/\/[^\s<>"']+/gi) || [];
const out = [];
for (let raw of matches) {
raw = raw.replace(/[),.;!?]+$/g, '');
const url = parseUrl(raw);
if (!url || !['http:', 'https:'].includes(url.protocol)) continue;
out.push(url.href);
}
return out;
}
function firstHttpUrlInText(text) {
return httpUrlsInText(text)[0] || null;
}
function shareUrlIdentity(rawUrl) {
const url = parseUrl(rawUrl);
if (!url || !['http:', 'https:'].includes(url.protocol)) return null;
const platform = platformForUrl(url.href);
const canonicalUrl = platform ? canonicalize(platform, url.href) : null;
if (canonicalUrl) return `post:${platform.id}:${canonicalUrl}`;
const alias = platform?.id === 'threads' ? threadsShareAlias(url.href) : null;
if (alias) return `alias:threads:${alias}`;
url.hash = '';
return `url:${url.href}`;
}
function uniqueShareCandidates({ title = '', text = '', url = '' } = {}) {
const candidates = [];
const seen = new Set();
const add = (raw, source, ordinal = 0) => {
const value = String(raw || '').trim();
if (!value) return;
const parsed = parseUrl(value);
if (!parsed || !['http:', 'https:'].includes(parsed.protocol)) return;
const href = parsed.href;
const identity = shareUrlIdentity(href) || `url:${href}`;
if (seen.has(identity)) return;
seen.add(identity);
candidates.push({ href, identity, source, ordinal });
};
add(url, 'url', 0);
httpUrlsInText(text).forEach((href, index) => add(href, 'text', index));
httpUrlsInText(title).forEach((href, index) => add(href, 'title', index));
return candidates;
}
function normalizeSharedText(text, primaryUrls = []) {
const primary = new Set(primaryUrls.map(shareUrlIdentity).filter(Boolean));
const seen = new Set();
let output = String(text || '').replace(/https?:\/\/[^\s<>"']+/gi, (raw) => {
const trailing = raw.match(/[),.;!?]+$/)?.[0] || '';
const core = trailing ? raw.slice(0, -trailing.length) : raw;
const identity = shareUrlIdentity(core);
if (!identity) return raw;
if (primary.has(identity) || seen.has(identity)) return '';
seen.add(identity);
return core;
});
output = output
.replace(/[ \t]{2,}/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n');
return cleanText(output);
}
const CAPTURE_HANDOFF_VERSION = 'v1';
const CAPTURE_HANDOFF_KEY = 'sptCapture';
const CAPTURE_MODES = Object.freeze(['smart', 'post']);
function normalizeCaptureMode(value) {
return CAPTURE_MODES.includes(String(value || '').toLowerCase())
? String(value).toLowerCase()
: 'smart';
}
function makeCaptureHandoffUrl(rawUrl, { mode = 'smart' } = {}) {
const platform = platformForUrl(rawUrl);
const canonicalUrl = platform ? canonicalize(platform, rawUrl) : null;
const shareAlias = platform?.id === 'threads' ? threadsShareAlias(rawUrl) : null;
if (!platform || (!canonicalUrl && !shareAlias)) return null;
const sourceUrl = canonicalUrl || shareAlias;
const url = parseUrl(sourceUrl);
if (!url) return null;
const params = new URLSearchParams();
params.set(CAPTURE_HANDOFF_KEY, CAPTURE_HANDOFF_VERSION);
params.set('mode', normalizeCaptureMode(mode));
url.hash = params.toString();
return url.href;
}
function parseCaptureHandoff(rawUrl, base) {
const url = parseUrl(rawUrl, base);
if (!url || !url.hash) return null;
const params = new URLSearchParams(url.hash.slice(1));
if (params.get(CAPTURE_HANDOFF_KEY) !== CAPTURE_HANDOFF_VERSION) return null;
const platform = platformForUrl(url.href);
const canonicalUrl = platform ? canonicalize(platform, url.href) : null;
const shareAlias = platform?.id === 'threads' ? threadsShareAlias(url.href) : null;
if (!platform || (!canonicalUrl && !shareAlias)) return null;
return {
version: CAPTURE_HANDOFF_VERSION,
platform: platform.id,
canonicalUrl,
sourceUrl: canonicalUrl || shareAlias,
unresolved: !canonicalUrl && Boolean(shareAlias),
mode: normalizeCaptureMode(params.get('mode')),
};
}
function threadsShareAlias(rawUrl, base) {
const url = parseUrl(rawUrl, base);
if (!url) return null;
const platform = platformForUrl(url.href);
if (!platform || platform.id !== 'threads') return null;
const match = /^\/share\/([A-Za-z0-9_-]+)\/?$/i.exec(url.pathname);
if (!match) return null;
return `${platform.canonicalOrigin}/share/${match[1]}`;
}
const SHARE_PIPELINE_SCHEMA = 'social-share-pipeline/v1';
const SHARE_COLLECTIONS = Object.freeze({
x: Object.freeze({
id: 'x',
platform: 'x',
parserIds: Object.freeze(['x-post']),
capabilities: Object.freeze(['clean-link', 'alternate-link', 'ai-capture', 'archive']),
}),
threads: Object.freeze({
id: 'threads',
platform: 'threads',
parserIds: Object.freeze(['threads-post', 'threads-share-alias']),
enricherIds: Object.freeze(['threads-share-resolver']),
capabilities: Object.freeze(['clean-link', 'alternate-link', 'ai-capture', 'archive']),
}),
});
const SHARE_PARSERS = Object.freeze([
Object.freeze({
id: 'x-post',
stage: 's01-parse',
priority: 200,
parse(candidate) {
const platform = platformForUrl(candidate.href);
if (platform?.id !== 'x') return null;
const canonicalUrl = canonicalize(platform, candidate.href);
return canonicalUrl ? { platform: 'x', canonicalUrl, sharedUrl: candidate.href, shareKind: 'post', needsResolution: false } : null;
},
}),
Object.freeze({
id: 'threads-post',
stage: 's01-parse',
priority: 200,
parse(candidate) {
const platform = platformForUrl(candidate.href);
if (platform?.id !== 'threads') return null;
const canonicalUrl = canonicalize(platform, candidate.href);
return canonicalUrl ? { platform: 'threads', canonicalUrl, sharedUrl: candidate.href, shareKind: 'post', needsResolution: false } : null;
},
}),
Object.freeze({
id: 'threads-share-alias',
stage: 's01-parse',
priority: 100,
parse(candidate) {
const alias = threadsShareAlias(candidate.href);
return alias ? { platform: 'threads', canonicalUrl: null, sharedUrl: alias, shareKind: 'share-alias', needsResolution: true } : null;
},
}),
]);
function runSharePipeline({ title = '', text = '', url = '' } = {}) {
const candidates = uniqueShareCandidates({ title, text, url });
const parsedCandidates = [];
for (const candidate of candidates) {
for (const parser of SHARE_PARSERS) {
const parsed = parser.parse(candidate);
if (!parsed) continue;
parsedCandidates.push({ parser, candidate, parsed });
break;
}
}
parsedCandidates.sort((a, b) => {
if (a.parser.priority !== b.parser.priority) return b.parser.priority - a.parser.priority;
const sourceRank = { url: 3, text: 2, title: 1 };
const sourceDiff = (sourceRank[b.candidate.source] || 0) - (sourceRank[a.candidate.source] || 0);
if (sourceDiff) return sourceDiff;
return a.candidate.ordinal - b.candidate.ordinal;
});
const winner = parsedCandidates[0] || null;
const fallback = candidates[0]?.href || null;
const primaryUrls = winner
? [winner.candidate.href, winner.parsed.canonicalUrl, winner.parsed.sharedUrl].filter(Boolean)
: [fallback].filter(Boolean);
const normalizedText = normalizeSharedText(text, primaryUrls);
const base = winner ? {
supported: true,
...winner.parsed,
} : {
supported: false,
platform: null,
canonicalUrl: null,
sharedUrl: fallback,
shareKind: 'unknown',
needsResolution: false,
};
return {
...base,
title: normalizeSharedText(title, primaryUrls),
text: normalizedText,
pipeline: {
schema: SHARE_PIPELINE_SCHEMA,
collection: base.platform && SHARE_COLLECTIONS[base.platform] ? SHARE_COLLECTIONS[base.platform].id : null,
parser: winner?.parser.id || null,
stages: [
{ id: 's00-raw', candidateCount: candidates.length },
{ id: 's01-parse', parser: winner?.parser.id || null },
{ id: 's02-enrich', status: base.needsResolution ? 'pending' : 'local' },
],
},
};
}
function parseIncomingShare(envelope = {}) {
return runSharePipeline(envelope);
}
function canonicalJsonValue(value) {
if (value === null) return null;
const type = typeof value;
if (type === 'string' || type === 'boolean') return value;
if (type === 'number') return Number.isFinite(value) ? value : null;
if (type === 'bigint') return String(value);
if (Array.isArray(value)) {
return value.map((item) => {
const itemType = typeof item;
if (itemType === 'undefined' || itemType === 'function' || itemType === 'symbol') return null;
return canonicalJsonValue(item);
});
}
if (type === 'object') {
if (typeof value.toJSON === 'function' && !(value instanceof Object && Object.getPrototypeOf(value) === Object.prototype)) {
return canonicalJsonValue(value.toJSON());
}
const out = {};
for (const key of Object.keys(value).sort()) {
const item = value[key];
const itemType = typeof item;
if (itemType === 'undefined' || itemType === 'function' || itemType === 'symbol') continue;
out[key] = canonicalJsonValue(item);
}
return out;
}
return null;
}
function stableJsonStringify(value) {
return JSON.stringify(canonicalJsonValue(value));
}
async function sha256Hex(input) {
const subtle = globalThis.crypto?.subtle;
if (!subtle || typeof subtle.digest !== 'function') throw new Error('sha256-unavailable');
let bytes;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof ArrayBuffer) {
bytes = new Uint8Array(input);
} else if (ArrayBuffer.isView(input)) {
bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
} else {
throw new TypeError('sha256-input-must-be-string-or-bytes');
}
const digest = await subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function makePortableLinkSettings(settings = {}) {
const links = settings.links && typeof settings.links === 'object' ? settings.links : {};
const rawCustom = Array.isArray(settings.builders?.custom) ? settings.builders.custom : [];
const security = settings.security && typeof settings.security === 'object' ? settings.security : {};
const allowInsecureCustomUrls = Boolean(security.allowInsecureCustomUrls);
const registry = builderRegistry(rawCustom, { allowInsecureHttp: allowInsecureCustomUrls });
const custom = registry.filter((builder) => !builder.builtin);
const options = { allowInsecureHttp: allowInsecureCustomUrls };
return {
schema: 'social-post-tools-links/v1',
links: {
x: { builderId: normalizeBuilderId('x', links.x?.builderId, custom, options) },
threads: { builderId: normalizeBuilderId('threads', links.threads?.builderId, custom, options) },
},
builders: { custom },
security: { allowInsecureCustomUrls },
};
}
function sanitizePortableLinkSettings(raw) {
if (!raw || raw.schema !== 'social-post-tools-links/v1') return null;
const allowInsecureCustomUrls = Boolean(raw.security?.allowInsecureCustomUrls);
const registry = builderRegistry(raw.builders?.custom || [], { allowInsecureHttp: allowInsecureCustomUrls });
const custom = registry.filter((builder) => !builder.builtin);
const options = { allowInsecureHttp: allowInsecureCustomUrls };
return {
schema: 'social-post-tools-links/v1',
links: {
x: { builderId: normalizeBuilderId('x', raw.links?.x?.builderId, custom, options) },
threads: { builderId: normalizeBuilderId('threads', raw.links?.threads?.builderId, custom, options) },
},
builders: { custom },
security: { allowInsecureCustomUrls },
};
}
const ACTIVE_BUILTIN_BUILDERS = Object.freeze(BUILTIN_BUILDERS.filter((builder) => !builder.retired));
return Object.freeze({
VERSION,
PLATFORMS,
DEFAULT_BUILDERS,
ALL_BUILTIN_BUILDERS: BUILTIN_BUILDERS,
BUILTIN_BUILDERS: ACTIVE_BUILTIN_BUILDERS,
MAX_BUILDER_URL_CHARS,
MAX_CUSTOM_BUILDERS,
parseUrl,
cleanText,
boundedString,
platformById,
platformForUrl,
defaultBuilderId,
canonicalize,
postParts,
hasUrlCredentials,
isLoopbackHost,
customUrlAllowed,
normalizeCustomBuilder,
builderRegistry,
compatibleBuilders,
builderById,
builderVars,
applyTemplate,
finalizeBuiltUrl,
buildUrl,
selectBuilder,
normalizeBuilderId,
transformedUrl,
httpUrlsInText,
firstHttpUrlInText,
shareUrlIdentity,
uniqueShareCandidates,
normalizeSharedText,
CAPTURE_HANDOFF_VERSION,
CAPTURE_HANDOFF_KEY,
CAPTURE_MODES,
normalizeCaptureMode,
makeCaptureHandoffUrl,
parseCaptureHandoff,
SHARE_PIPELINE_SCHEMA,
SHARE_COLLECTIONS,
SHARE_PARSERS,
runSharePipeline,
parseIncomingShare,
threadsShareAlias,
stableJsonStringify,
sha256Hex,
makePortableLinkSettings,
sanitizePortableLinkSettings,
});
});
(() => {
'use strict';
const CORE = globalThis.SocialPostCore;
if (!CORE) throw new Error('SocialPostCore failed to initialize');
const APP = Object.freeze({
id: 'social-post-tools',
version: '4.4.0',
settingsKey: 'social-post-tools:settings',
captureCacheKey: 'social-post-tools:capture-cache:v1',
captureResumeKey: 'social-post-tools:capture-resume:v1',
actionTtlMs: 3500,
injectWatchMs: 1200,
injectFallbackMs: [0, 60, 180, 420],
maxCustomBuilders: 32,
maxBuilderUrlChars: 8192,
mediaPackageMaxFiles: 8,
mediaPackageMaxFileBytes: 12 * 1024 * 1024,
mediaPackageMaxTotalBytes: 32 * 1024 * 1024,
mediaFetchTimeoutMs: 15000,
handoffWatchMs: 10000,
handoffMutationDebounceMs: 70,
captureResumeTtlMs: 10 * 60 * 1000,
captureCacheMaxEntries: 8,
captureCacheMaxEntryChars: 512 * 1024,
captureCacheMaxTotalChars: 1536 * 1024,
captureRevisionMaxEvents: 6,
});
function handleCaptureBridgePage() {
if (!/\/capture-handoff\.html$/i.test(location.pathname)) return false;
const params = new URLSearchParams(location.search);
const raw = params.get('url') || '';
const mode = CORE.normalizeCaptureMode(params.get('mode'));
const target = CORE.makeCaptureHandoffUrl(raw, { mode });
const mark = (state) => {
const root = document.documentElement;
if (!root) return;
root.dataset.sptCaptureBridgeSource = raw;
root.dataset.sptCaptureBridgeMode = mode;
root.dataset.sptCaptureBridge = state;
};
try { history.replaceState(history.state, '', location.pathname); } catch {}
if (!target) {
mark('invalid');
return true;
}
mark('handled');
try {
if (typeof GM_openInTab !== 'function') throw new Error('GM_openInTab unavailable');
GM_openInTab(target, { active: true, insert: true, setParent: true });
} catch {
mark('failed');
}
return true;
}
if (handleCaptureBridgePage()) return;
const TEXT = Object.freeze({
entry: 'Post tools ›',
back: '← Back',
backToTools: '← Post tools',
backToCapture: '← Use with AI',
capture: 'Use with AI ›',
archive: 'Archive snapshot ›',
backToArchive: '← Post tools',
archivePrepare: 'Prepare archive · post + context',
archivePrepareDiscussion: 'Prepare archive · visible discussion',
archivePrepareMedia: 'Prepare archive + images',
archivePreparing: 'Preparing archive…',
archiveIntegrityNote: 'SHA-256 checks file integrity only; it does not prove source authenticity, authorship, or publication time.',
archiveHash: (hash) => `Snapshot SHA-256 · ${String(hash || '').slice(0, 12)}…`,
archiveCopyHash: 'Copy snapshot SHA-256',
archiveShare: (count) => `Share archive package${count ? ` · ${count} files` : ''}…`,
archiveClear: 'Clear prepared archive',
captureSmart: (summary) => `Copy smart capture${summary ? ` · ${summary}` : ''}`,
capturePost: 'Copy this post only',
captureContext: (summary) => `Post + context${summary ? ` · ${summary}` : ''}`,
captureDiscussion: (count = null) => count == null ? 'Copy with visible replies' : `Copy with visible replies · ${count} repl${count === 1 ? 'y' : 'ies'}`,
captureOptions: 'Capture options ›',
recentCapture: 'Recent capture ›',
backToRecentCapture: '← Use with AI',
shareSmartCapture: 'Share smart capture…',
moreTools: 'More tools ›',
backToMoreTools: '← Post tools',
copyCaptureJson: 'Copy capture JSON',
shareCapture: 'Share capture to apps…',
preparePackage: 'Prepare package with images',
preparingPackage: 'Preparing package…',
sharePreparedPackage: (count) => `Share prepared package${count ? ` · ${count} files` : ''}…`,
clearPreparedPackage: 'Clear prepared package',
copyClean: 'Copy original link',
copyAlternate: 'Copy share link',
telegram: 'Send to Telegram',
systemShare: 'Share…',
openAlternate: 'Open share link',
settings: 'Settings',
copied: 'Copied',
captured: (summary) => `Captured${summary ? ` · ${summary}` : ''}`,
failed: 'Unavailable',
copiedInstead: 'Copied instead',
shared: 'Shared',
handoffReady: 'AI capture ready',
handoffPreparing: 'Preparing AI capture…',
handoffFailed: 'Could not prepare AI capture',
handoffCopy: 'Copy capture',
handoffShare: 'Share to apps…',
handoffDismiss: 'Dismiss',
handoffResumed: 'AI capture resumed',
resumeCachedCapture: 'Resume cached capture',
forgetCachedCapture: 'Forget cached capture',
refreshCapture: 'Refresh capture',
handoffRefreshed: 'AI capture refreshed',
revisionHistory: (count) => `Revision history${count ? ` · ${count} change${count === 1 ? '' : 's'}` : ''} ›`,
backToRevisionHistory: '← Use with AI',
});
const SEMANTIC_LABELS = Object.freeze({
share: [
'share', 'share post',
'分享', '分享貼文', '分享帖子', '共享',
'シェア', '共有', '공유',
'compartir', 'partager', 'teilen', 'condividi',
'compartilhar', 'delen', 'udostępnij', 'paylaş',
],
});
const BUILTIN_BUILDERS = CORE.BUILTIN_BUILDERS;
const SAFE_IMAGE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif']);
const ACTION_DEFS = Object.freeze({
capture: { label: 'Use with AI', defaultEnabled: true },
archive: { label: 'Archive snapshot', defaultEnabled: true },
copyClean: { label: 'Copy original link', defaultEnabled: true },
copyAlternate: { label: 'Copy share link', defaultEnabled: true },
telegram: { label: 'Send to Telegram', defaultEnabled: true },
systemShare: { label: 'Share', defaultEnabled: true },
openAlternate: { label: 'Open share link', defaultEnabled: true },
settings: { label: 'Settings', defaultEnabled: true },
});
const DEFAULT_SETTINGS = Object.freeze({
schemaVersion: 10,
actions: {
enabled: Object.fromEntries(Object.entries(ACTION_DEFS).map(([id, def]) => [id, def.defaultEnabled])),
order: ['capture', 'archive', 'copyClean', 'copyAlternate', 'telegram', 'systemShare', 'openAlternate', 'settings'],
},
links: {
x: { builderId: 'nitter-net' },
threads: { builderId: 'vxthreads' },
},
builders: {
custom: [],
},
capture: {
defaultMode: 'smart',
includeMedia: true,
includeMetrics: true,
richHtmlImages: false,
maxVisibleReplies: 12,
cacheEnabled: true,
cacheTtlMinutes: 20,
freshMinutes: 5,
},
telegram: {
linkSource: 'selected',
includeAuthor: true,
includeText: true,
maxText: 700,
template: '{author}\n\n{text}',
},
systemShare: {
linkSource: 'selected',
includeAuthor: true,
includeText: true,
maxText: 700,
template: '{author}\n\n{text}',
},
security: {
sanitizeNativeCopy: true,
allowInsecureCustomUrls: false,
},
ui: {
statusFeedback: true,
menuStyle: 'simple',
},
});
const SITES = [
{
id: 'x',
platformName: 'X',
hosts: CORE.PLATFORMS.x.hosts,
canonicalOrigin: CORE.PLATFORMS.x.canonicalOrigin,
shareSelectors: [
'button[aria-haspopup="menu"][aria-label="Share post"]',
'[data-testid="share"]',
'[data-testid="shareButton"]',
],
shareSemantics: ['share'],
menu: {
surfaceSelectors: ['[role="menu"]'],
containerSelectors: ['[data-testid="Dropdown"]'],
itemSelectors: ['[role="menuitem"]'],
placement: 'append',
templateIndices: {
default: 1,
copy: 1,
capture: 1,
open: 1,
settings: 1,
telegram: 2,
share: 2,
back: 1,
},
},
identityScopeSelectors: ['[role="group"]', '[data-testid="tweet"]', 'article'],
postLinkSelectors: [
'a[href*="/status/"]:has(time)',
'a[href*="/status/"][href*="/analytics"]',
'a[href*="/status/"]',
],
urlRules: [
{
match: /^\/([^/]+)\/status\/(\d+)(?:\/.*)?$/,
build: (match) => `/${match[1]}/status/${match[2]}`,
parts: (match) => ({ author: decodeURIComponent(match[1]), postId: match[2] }),
},
],
content: {
rootSelectors: ['article[data-testid="tweet"]', '[data-testid="tweet"]', 'article'],
ownershipRootSelectors: ['article[data-testid="tweet"]', '[data-testid="tweet"]', 'article'],
nestedPostRootSelectors: ['article[data-testid="tweet"]', '[data-testid="tweet"]'],
bodySelectors: ['[data-testid="tweetText"]'],
fallbackTextSelectors: ['[dir="auto"]'],
bodyExcludeAncestorSelectors: ['a', 'button', '[role="button"]', '[role="menu"]', '[role="dialog"]'],
timestampSelectors: ['time[datetime]'],
authorContainerSelectors: ['[data-testid="User-Name"]'],
mediaImageSelectors: ['[data-testid="tweetPhoto"] img'],
mediaVideoSelectors: ['video'],
repostContextSelectors: ['[data-testid="socialContext"]'],
repostActorLinkSelectors: ['[data-testid="socialContext"] a[href]'],
discussionRootSelectors: ['article[data-testid="tweet"]', '[data-testid="tweet"]'],
},
},
{
id: 'threads',
platformName: 'Threads',
hosts: CORE.PLATFORMS.threads.hosts,
canonicalOrigin: CORE.PLATFORMS.threads.canonicalOrigin,
shareSelectors: [
'[role="button"]:has(svg[aria-label="Share"])',
'button:has(svg[aria-label="Share"])',
],
shareSemantics: ['share'],
menu: {
surfaceSelectors: ['[role="menu"]', '[role="dialog"]'],
containerSelectors: [],
itemSelectors: ['[role="menuitem"]', 'button', '[role="button"]'],
placement: 'append',
templateIndices: {},
},
identityScopeSelectors: ['[data-pressable-container="true"]', '[data-pagelet^="threads_"]'],
postLinkSelectors: ['a[href*="/post/"]', 'a[href*="/t/"]'],
urlRules: [
{
match: /^\/(?:@|%40)([^/]+)\/post\/([A-Za-z0-9_-]+)(?:\/.*)?$/i,
build: (match) => `/@${match[1]}/post/${match[2]}`,
parts: (match) => ({ author: decodeURIComponent(match[1]), postId: match[2] }),
},
{
match: /^\/t\/([A-Za-z0-9_-]+)(?:\/.*)?$/i,
build: (match) => `/t/${match[1]}`,
parts: (match) => ({ author: null, postId: match[1] }),
},
],
content: {
rootSelectors: ['[data-pressable-container="true"]', 'article', '[data-pagelet^="threads_"]'],
ownershipRootSelectors: ['[data-pressable-container="true"]'],
nestedPostRootSelectors: ['[data-pressable-container="true"]'],
bodySelectors: ['[data-text="true"]', '[dir="auto"]'],
fallbackTextSelectors: ['[dir="auto"]'],
bodyExcludeAncestorSelectors: ['a', 'button', '[role="button"]', '[role="menu"]', '[role="dialog"]'],
bodyExcludeSelectors: ['[translate="no"]'],
bodyExcludeDescendantSelectors: ['time'],
timestampSelectors: ['time[datetime]'],
authorContainerSelectors: [],
mediaImageSelectors: ['a[href*="/media"] img'],
mediaVideoSelectors: ['a[href*="/media"] video', 'video'],
repostContextSelectors: [],
repostActorLinkSelectors: [],
discussionRootSelectors: ['[data-pressable-container="true"]'],
},
},
].map(prepareSite);
const state = {
settings: null,
pending: null,
injectObserver: null,
injectRaf: 0,
injectStopTimer: 0,
injectFallbackTimers: [],
panel: null,
settingsDialog: null,
site: null,
clipboardPatch: null,
volatileStore: new Map(),
handoff: null,
handoffObserver: null,
handoffStopTimer: 0,
handoffScanTimer: 0,
handoffBar: null,
};
// Capture handoff is carried only in the URL fragment, so it is never sent
// to X/Threads servers. Consume and remove it before the site app starts.
state.handoff = CORE.parseCaptureHandoff(location.href);
if (state.handoff) {
const cleanHandoffUrl = state.handoff.canonicalUrl || state.handoff.sourceUrl;
if (cleanHandoffUrl) {
try { history.replaceState(history.state, '', cleanHandoffUrl); } catch {}
}
}
// ---------- Generic utilities ----------
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function mergeDefaults(defaults, value) {
if (Array.isArray(defaults)) return Array.isArray(value) ? value.slice() : defaults.slice();
if (!isPlainObject(defaults)) return value === undefined ? defaults : value;
const out = {};
const source = isPlainObject(value) ? value : {};
for (const [key, fallback] of Object.entries(defaults)) {
out[key] = mergeDefaults(fallback, source[key]);
}
for (const [key, extra] of Object.entries(source)) {
if (!(key in out)) out[key] = extra;
}
return out;
}
function normalizeLabel(value) {
return String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[\s\u00A0]+/g, ' ')
.replace(/[.…]+$/u, '')
.trim();
}
function cleanText(value) {
return String(value || '')
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, '')
.replace(/\r/g, '')
.split('\n')
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
.filter(Boolean)
.join('\n')
.trim();
}
function uniqueValues(values) {
const seen = new Set();
return values.filter((value) => {
const text = String(value || '');
const key = text.toLocaleLowerCase();
if (!text || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function clampInt(value, min, max, fallback) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function parseUrl(raw, base = location.href) {
try {
return new URL(raw, base);
} catch {
return null;
}
}
function validHttpUrl(raw, base = location.href) {
const url = parseUrl(raw, base);
if (!url || !['http:', 'https:'].includes(url.protocol)) return null;
return url;
}
function stripTrailingSlash(raw) {
return String(raw || '').replace(/\/+$/, '');
}
function queryAll(root, selector) {
try {
return [...(root?.querySelectorAll?.(selector) || [])];
} catch {
return [];
}
}
function queryMany(root, selectors) {
const seen = new Set();
const out = [];
for (const selector of selectors || []) {
for (const element of queryAll(root, selector)) {
if (seen.has(element)) continue;
seen.add(element);
out.push(element);
}
}
return out;
}
function matchesAny(element, selectors) {
if (!(element instanceof Element)) return false;
for (const selector of selectors || []) {
try {
if (element.matches(selector)) return true;
} catch {}
}
return false;
}
function isVisible(element) {
if (!(element instanceof Element)) return false;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
}
function semanticLabels(names) {
return (names || []).flatMap((name) => SEMANTIC_LABELS[name] || []);
}
function prepareSite(site) {
const labels = semanticLabels(site.shareSemantics);
return {
...site,
hostSet: new Set(site.hosts.map((host) => host.toLowerCase())),
shareLabelSet: new Set(labels.map(normalizeLabel).filter(Boolean)),
menu: {
surfaceSelectors: ['[role="menu"]', '[role="dialog"]'],
containerSelectors: [],
itemSelectors: ['[role="menuitem"]', 'button', '[role="button"]'],
placement: 'append',
templateIndices: {},
...(site.menu || {}),
},
};
}
function currentSite() {
const host = location.hostname.toLowerCase();
return SITES.find((site) => site.hostSet.has(host)) || null;
}
function canonicalize(site, rawUrl) {
return CORE.canonicalize(site?.id || site, rawUrl, location.href);
}
function postParts(site, canonicalUrl) {
return CORE.postParts(site?.id || site, canonicalUrl);
}
// ---------- Settings + migrations ----------
function gmGet(key, fallback = null) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
} catch {}
return state.volatileStore.has(key) ? deepClone(state.volatileStore.get(key)) : fallback;
}
function gmSet(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
return true;
}
} catch {}
state.volatileStore.set(key, deepClone(value));
return true;
}
function gmDelete(key) {
try {
if (typeof GM_deleteValue === 'function') GM_deleteValue(key);
} catch {}
state.volatileStore.delete(key);
}
function oldLocalStorageValue(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
function migrateLegacySettings(settings) {
const site = currentSite();
if (!site) return settings;
if (site.id === 'x') {
const old = oldLocalStorageValue('social-mirror-share-copy:x:provider')
|| oldLocalStorageValue('x-mirror-share-copy:selected-mirror')
|| oldLocalStorageValue('x-mirror-share-copy:selected-provider');
const map = {
nitter: 'nitter-net',
'nitter-auto': 'nitter-net',
fixupx: 'fixupx',
fixvx: 'fixvx',
};
if (old && map[old]) settings.links.x.builderId = map[old];
}
if (site.id === 'threads') {
const old = oldLocalStorageValue('social-mirror-share-copy:threads:provider')
|| oldLocalStorageValue('x-mirror-share-copy:threads:selected-provider');
if (old === 'vxthreads') settings.links.threads.builderId = 'vxthreads';
}
return settings;
}
function sanitizeSettings(raw) {
const sourceSchemaVersion = Number(raw?.schemaVersion || 0);
const settings = mergeDefaults(DEFAULT_SETTINGS, raw);
settings.schemaVersion = 10;
if (sourceSchemaVersion > 0 && sourceSchemaVersion < 4) settings.actions.enabled.systemShare = true;
settings.security.sanitizeNativeCopy = settings.security.sanitizeNativeCopy !== false;
settings.security.allowInsecureCustomUrls = Boolean(settings.security.allowInsecureCustomUrls);
settings.capture.includeMedia = settings.capture.includeMedia !== false;
settings.capture.includeMetrics = settings.capture.includeMetrics !== false;
settings.capture.richHtmlImages = Boolean(settings.capture.richHtmlImages);
settings.capture.cacheEnabled = settings.capture.cacheEnabled !== false;
settings.ui.statusFeedback = settings.ui.statusFeedback !== false;
settings.ui.menuStyle = settings.ui.menuStyle === 'custom' ? 'custom' : 'simple';
if (sourceSchemaVersion > 0 && sourceSchemaVersion < 10 && !raw?.ui?.menuStyle) {
const oldOrder = ['capture', 'archive', 'copyClean', 'copyAlternate', 'telegram', 'systemShare', 'openAlternate', 'settings'];
const rawOrder = Array.isArray(raw?.actions?.order) ? raw.actions.order : oldOrder;
const reordered = rawOrder.join('|') !== oldOrder.join('|');
const hidden = Object.values(raw?.actions?.enabled || {}).some((value) => value === false);
settings.ui.menuStyle = reordered || hidden ? 'custom' : 'simple';
}
const validActionIds = new Set(Object.keys(ACTION_DEFS));
settings.actions.order = uniqueValues((settings.actions.order || []).filter((id) => validActionIds.has(id)));
for (const id of validActionIds) {
if (!settings.actions.order.includes(id)) settings.actions.order.push(id);
settings.actions.enabled[id] = Boolean(settings.actions.enabled[id]);
}
settings.capture.maxVisibleReplies = clampInt(settings.capture.maxVisibleReplies, 0, 50, 12);
settings.capture.cacheTtlMinutes = clampInt(settings.capture.cacheTtlMinutes, 1, 120, 20);
settings.capture.freshMinutes = clampInt(settings.capture.freshMinutes, 1, settings.capture.cacheTtlMinutes, Math.min(5, settings.capture.cacheTtlMinutes));
settings.telegram.maxText = clampInt(settings.telegram.maxText, 0, 4000, 700);
settings.systemShare.maxText = clampInt(settings.systemShare.maxText, 0, 4000, 700);
settings.telegram.template = boundedString(settings.telegram.template || '{author}\n\n{text}', 8000);
settings.systemShare.template = boundedString(settings.systemShare.template || '{author}\n\n{text}', 8000);
if (!['smart', 'post', 'discussion'].includes(settings.capture.defaultMode)) settings.capture.defaultMode = 'smart';
// Preserve existing v3.2 HTTP custom builders during migration, but new
// installs require HTTPS (loopback HTTP is always allowed).
const rawCustom = Array.isArray(raw?.builders?.custom) ? raw.builders.custom : [];
if (sourceSchemaVersion > 0 && sourceSchemaVersion < 5 && rawCustom.some((item) => /^http:\/\//i.test(item?.baseUrl || item?.template || ''))) {
settings.security.allowInsecureCustomUrls = true;
}
const usedIds = new Set(BUILTIN_BUILDERS.map((builder) => builder.id));
const custom = [];
for (const item of (settings.builders.custom || []).slice(0, APP.maxCustomBuilders)) {
const builder = normalizeCustomBuilder(item, { allowInsecureHttp: settings.security.allowInsecureCustomUrls });
if (!builder || usedIds.has(builder.id)) continue;
usedIds.add(builder.id);
custom.push(builder);
}
settings.builders.custom = custom;
for (const platform of ['x', 'threads']) {
const selected = String(settings.links?.[platform]?.builderId || '');
const exists = [...BUILTIN_BUILDERS, ...custom].some((builder) => builder.id === selected && builder.platforms.includes(platform));
if (!exists) settings.links[platform].builderId = DEFAULT_SETTINGS.links[platform].builderId;
}
return settings;
}
function loadSettings() {
const raw = gmGet(APP.settingsKey, null);
let settings = sanitizeSettings(raw || deepClone(DEFAULT_SETTINGS));
if (!raw) {
settings = migrateLegacySettings(settings);
gmSet(APP.settingsKey, settings);
}
return settings;
}
function saveSettings(next) {
state.settings = sanitizeSettings(next);
gmSet(APP.settingsKey, state.settings);
syncClipboardSanitizer();
if (!state.settings.capture.cacheEnabled) {
gmDelete(APP.captureCacheKey);
gmDelete(APP.captureResumeKey);
}
return state.settings;
}
function resetSettings() {
gmDelete(APP.settingsKey);
state.settings = sanitizeSettings(deepClone(DEFAULT_SETTINGS));
gmSet(APP.settingsKey, state.settings);
syncClipboardSanitizer();
}
// ---------- Short-lived capture cache ----------
function captureCacheTtlMs() {
return clampInt(state.settings?.capture?.cacheTtlMinutes, 1, 120, 20) * 60 * 1000;
}
function captureFreshnessMs() {
const ttlMinutes = clampInt(state.settings?.capture?.cacheTtlMinutes, 1, 120, 20);
return clampInt(state.settings?.capture?.freshMinutes, 1, ttlMinutes, Math.min(5, ttlMinutes)) * 60 * 1000;
}
function captureAgeMs(capture, now = Date.now()) {
const captured = Date.parse(capture?.capturedAt || '');
if (!Number.isFinite(captured)) return null;
return Math.max(0, now - captured);
}
function formatCaptureAge(ageMs) {
if (!Number.isFinite(ageMs)) return 'unknown age';
if (ageMs < 60 * 1000) return 'just now';
const minutes = Math.floor(ageMs / (60 * 1000));
if (minutes < 60) return `${minutes}m old`;
const hours = Math.floor(minutes / 60);
const remain = minutes % 60;
return remain ? `${hours}h ${remain}m old` : `${hours}h old`;
}
function captureFreshness(capture, now = Date.now()) {
const ageMs = captureAgeMs(capture, now);
const freshForMs = captureFreshnessMs();
const stale = ageMs == null ? true : ageMs > freshForMs;
return {
ageMs,
freshForMs,
stale,
label: ageMs == null ? 'unknown age' : formatCaptureAge(ageMs),
};
}
function mediaIdentityUrl(raw) {
const parsed = parseUrl(raw);
if (!parsed || !/^https?:$/.test(parsed.protocol)) return String(raw || '');
return `${parsed.origin}${parsed.pathname}`;
}
function compareCaptureSnapshots(previous, next) {
const prevFocal = previous?.focal || {};
const nextFocal = next?.focal || {};
const normalizeMedia = (items) => (items || []).map((item) => [
item?.type || '',
mediaIdentityUrl(item?.url),
mediaIdentityUrl(item?.previewUrl),
item?.alt || '',
Number(item?.width || 0),
Number(item?.height || 0),
]);
const normalizeContext = (capture) => ({
repost: capture?.context?.repost ? {
label: capture.context.repost.label || '',
actor: capture.context.repost.actor?.handle || capture.context.repost.actor?.displayName || '',
} : null,
embedded: (capture?.context?.embedded || []).map((item) => ({
relation: item?.relation || '',
url: item?.post?.url || item?.post?.id || '',
text: item?.post?.text || '',
media: normalizeMedia(item?.post?.media),
})),
});
return {
textChanged: String(prevFocal.text || '') !== String(nextFocal.text || ''),
mediaChanged: JSON.stringify(normalizeMedia(prevFocal.media)) !== JSON.stringify(normalizeMedia(nextFocal.media)),
metricsChanged: JSON.stringify(prevFocal.metrics || {}) !== JSON.stringify(nextFocal.metrics || {}),
contextChanged: JSON.stringify(normalizeContext(previous)) !== JSON.stringify(normalizeContext(next)),
};
}
function captureDiffSummary(diff) {
if (!diff) return '';
const parts = [];
if (diff.textChanged) parts.push('text changed');
if (diff.mediaChanged) parts.push('media changed');
if (diff.metricsChanged) parts.push('metrics changed');
if (diff.contextChanged) parts.push('context changed');
return parts.length ? parts.join(', ') : 'no visible changes';
}
function captureDiffKeys(diff) {
if (!diff) return [];
const changes = [];
if (diff.textChanged) changes.push('text');
if (diff.mediaChanged) changes.push('media');
if (diff.metricsChanged) changes.push('metrics');
if (diff.contextChanged) changes.push('context');
return changes;
}
function captureFingerprintPayload(capture) {
const focal = capture?.focal || {};
const normalizeMedia = (items) => (items || []).map((item) => ({
type: item?.type || '',
url: mediaIdentityUrl(item?.url),
previewUrl: mediaIdentityUrl(item?.previewUrl),
alt: item?.alt || '',
width: Number(item?.width || 0),
height: Number(item?.height || 0),
}));
return {
platform: capture?.platform || '',
url: focal.url || '',
text: focal.text || '',
publishedAt: focal.publishedAt || '',
media: normalizeMedia(focal.media),
metrics: focal.metrics || {},
repost: capture?.context?.repost ? {
label: capture.context.repost.label || '',
actor: capture.context.repost.actor?.handle || capture.context.repost.actor?.displayName || '',
} : null,
embedded: (capture?.context?.embedded || []).map((item) => ({
relation: item?.relation || '',
url: item?.post?.url || '',
id: item?.post?.id || '',
text: item?.post?.text || '',
media: normalizeMedia(item?.post?.media),
})),
};
}
function snapshotFingerprint(capture) {
const text = JSON.stringify(captureFingerprintPayload(capture));
// FNV-1a 64-bit is used only as a compact change fingerprint. It is not
// an integrity/authenticity primitive and must never be treated as one.
let hash = 0xcbf29ce484222325n;
const prime = 0x100000001b3n;
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
hash ^= BigInt(code & 0xff);
hash = BigInt.asUintN(64, hash * prime);
if (code > 0xff) {
hash ^= BigInt((code >>> 8) & 0xff);
hash = BigInt.asUintN(64, hash * prime);
}
}
return `fnv1a64:${hash.toString(16).padStart(16, '0')}`;
}
function shortFingerprint(fingerprint) {
const value = String(fingerprint || '').split(':').pop() || '';
return value ? value.slice(0, 8) : 'unknown';
}
function normalizeCaptureProvenance(raw, capture) {
const fallbackAt = capture?.capturedAt || new Date().toISOString();
const source = raw && raw.schema === 'social-capture-provenance/v1' ? raw : null;
const sourceFingerprint = typeof source?.currentFingerprint === 'string' && source.currentFingerprint.length <= 64
? source.currentFingerprint
: null;
const fingerprint = sourceFingerprint || snapshotFingerprint(capture);
const events = [];
for (const event of (source?.events || []).slice(-APP.captureRevisionMaxEvents)) {
const at = typeof event?.at === 'string' && Number.isFinite(Date.parse(event.at)) ? event.at : null;
const kind = event?.kind === 'refresh' ? 'refresh' : event?.kind === 'capture' ? 'capture' : null;
const eventFingerprint = typeof event?.fingerprint === 'string' ? event.fingerprint.slice(0, 64) : null;
const changes = Array.isArray(event?.changes)
? event.changes.filter((item) => ['initial', 'text', 'media', 'metrics', 'context'].includes(item)).slice(0, 5)
: [];
if (!at || !kind || !eventFingerprint) continue;
events.push({ at, kind, fingerprint: eventFingerprint, changes });
}
if (!events.length) {
events.push({ at: fallbackAt, kind: 'capture', fingerprint, changes: ['initial'] });
}
return {
schema: 'social-capture-provenance/v1',
baselineAt: typeof source?.baselineAt === 'string' && Number.isFinite(Date.parse(source.baselineAt)) ? source.baselineAt : events[0].at,
lastCapturedAt: typeof source?.lastCapturedAt === 'string' && Number.isFinite(Date.parse(source.lastCapturedAt)) ? source.lastCapturedAt : fallbackAt,
lastCheckedAt: typeof source?.lastCheckedAt === 'string' && Number.isFinite(Date.parse(source.lastCheckedAt)) ? source.lastCheckedAt : fallbackAt,
currentFingerprint: typeof source?.currentFingerprint === 'string' ? source.currentFingerprint.slice(0, 64) : fingerprint,
events: events.slice(-APP.captureRevisionMaxEvents),
};
}
function updateCaptureProvenance(existing, capture, { kind = 'capture', previousCapture = null } = {}) {
const now = capture?.capturedAt || new Date().toISOString();
const provenance = normalizeCaptureProvenance(existing, capture);
const fingerprint = snapshotFingerprint(capture);
provenance.lastCapturedAt = now;
if (kind === 'refresh') {
provenance.lastCheckedAt = now;
const diff = compareCaptureSnapshots(previousCapture, capture);
const changes = captureDiffKeys(diff);
if (changes.length && fingerprint !== provenance.currentFingerprint) {
provenance.events.push({ at: now, kind: 'refresh', fingerprint, changes });
provenance.events = provenance.events.slice(-APP.captureRevisionMaxEvents);
}
}
provenance.currentFingerprint = fingerprint;
return provenance;
}
function provenanceChangeCount(provenance) {
return (provenance?.events || []).filter((event) => event.kind === 'refresh' && event.changes?.length).length;
}
function formatRevisionTime(value) {
const date = new Date(value || 0);
if (!Number.isFinite(date.getTime())) return 'unknown time';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function emptyCaptureCache() {
return { schema: 'social-capture-cache/v1', entries: [] };
}
function captureCacheEntryChars(item) {
try {
return JSON.stringify({ capture: item?.capture, provenance: item?.provenance || null }).length;
} catch {
return Infinity;
}
}
function loadCaptureCache() {
if (state.settings?.capture?.cacheEnabled === false) return emptyCaptureCache();
const now = Date.now();
const raw = gmGet(APP.captureCacheKey, null);
const cache = raw && raw.schema === 'social-capture-cache/v1' && Array.isArray(raw.entries)
? raw
: emptyCaptureCache();
const seen = new Set();
const entries = [];
let totalChars = 0;
let shouldRewrite = cache !== raw;
for (const item of cache.entries) {
if (!item || typeof item.url !== 'string' || !item.capture || Number(item.expiresAt || 0) <= now) {
shouldRewrite = true;
continue;
}
if (seen.has(item.url)) {
shouldRewrite = true;
continue;
}
const hasPersistedDiscussion = Boolean(item.capture?.discussion?.posts?.length || item.capture?.discussion?.visiblePosts?.length);
const safeCapture = hasPersistedDiscussion ? cacheSafeCapture(item.capture) : item.capture;
if (!safeCapture) {
shouldRewrite = true;
continue;
}
if (hasPersistedDiscussion) shouldRewrite = true;
const hadValidProvenance = item.provenance?.schema === 'social-capture-provenance/v1';
const provenance = normalizeCaptureProvenance(item.provenance, safeCapture);
if (!hadValidProvenance) shouldRewrite = true;
const normalized = {
url: item.url,
storedAt: Number(item.storedAt || now),
expiresAt: Number(item.expiresAt),
capture: safeCapture,
provenance,
};
const serializedChars = captureCacheEntryChars(normalized);
if (!Number.isFinite(serializedChars) || serializedChars > APP.captureCacheMaxEntryChars) {
shouldRewrite = true;
continue;
}
if (totalChars + serializedChars > APP.captureCacheMaxTotalChars) {
shouldRewrite = true;
continue;
}
seen.add(item.url);
totalChars += serializedChars;
entries.push(normalized);
if (entries.length >= APP.captureCacheMaxEntries) {
if (cache.entries.length > entries.length) shouldRewrite = true;
break;
}
}
const normalizedRaw = { schema: 'social-capture-cache/v1', entries };
if (entries.length !== cache.entries.length) shouldRewrite = true;
if (shouldRewrite) gmSet(APP.captureCacheKey, normalizedRaw);
return normalizedRaw;
}
function cacheSafeCapture(capture) {
if (!capture?.focal?.url) return null;
let safe;
try { safe = deepClone(capture); } catch { return null; }
// Discussion can contain unrelated people/content and grows quickly. It is
// intentionally never persisted; the live capture still keeps it.
if (safe.discussion) {
safe.discussion.posts = [];
safe.discussion.visiblePosts = []; // v3.6 compatibility; never persist discussion content.
safe.discussion.capturedReplyCount = 0;
safe.discussion.complete = false;
safe.discussion.cacheOmitted = true;
}
return safe;
}
function rememberCapture(capture, { armResume = false, revisionKind = 'capture', previousCapture = null } = {}) {
if (state.settings?.capture?.cacheEnabled === false) return false;
const safe = cacheSafeCapture(capture);
if (!safe) return false;
const now = Date.now();
const expiresAt = now + captureCacheTtlMs();
const cache = loadCaptureCache();
const existing = cache.entries.find((item) => item.url === safe.focal.url) || null;
const provenance = updateCaptureProvenance(existing?.provenance, safe, {
kind: revisionKind,
previousCapture: previousCapture || existing?.capture || null,
});
const nextEntry = { url: safe.focal.url, storedAt: now, expiresAt, capture: safe, provenance };
const nextSize = captureCacheEntryChars(nextEntry);
if (!Number.isFinite(nextSize) || nextSize > APP.captureCacheMaxEntryChars) return false;
const entries = [
nextEntry,
...cache.entries.filter((item) => item.url !== safe.focal.url),
];
let totalChars = 0;
const kept = [];
for (const item of entries) {
const size = captureCacheEntryChars(item);
if (!Number.isFinite(size) || size > APP.captureCacheMaxEntryChars || totalChars + size > APP.captureCacheMaxTotalChars) continue;
kept.push(item);
totalChars += size;
if (kept.length >= APP.captureCacheMaxEntries) break;
}
gmSet(APP.captureCacheKey, { schema: 'social-capture-cache/v1', entries: kept });
if (armResume) {
gmSet(APP.captureResumeKey, {
schema: 'social-capture-resume/v1',
url: safe.focal.url,
expiresAt: Math.min(expiresAt, now + APP.captureResumeTtlMs),
});
}
return true;
}
function getCachedCaptureRecord(canonicalUrl) {
if (!canonicalUrl) return null;
const item = loadCaptureCache().entries.find((entry) => entry.url === canonicalUrl);
if (!item) return null;
return {
url: item.url,
storedAt: item.storedAt,
expiresAt: item.expiresAt,
capture: deepClone(item.capture),
provenance: deepClone(item.provenance),
};
}
function getCachedCapture(canonicalUrl) {
return getCachedCaptureRecord(canonicalUrl)?.capture || null;
}
function forgetCachedCapture(canonicalUrl) {
if (!canonicalUrl) return;
const cache = loadCaptureCache();
gmSet(APP.captureCacheKey, {
schema: 'social-capture-cache/v1',
entries: cache.entries.filter((item) => item.url !== canonicalUrl),
});
clearResumeTicket(canonicalUrl);
}
function clearResumeTicket(canonicalUrl = null) {
const ticket = gmGet(APP.captureResumeKey, null);
if (!ticket) return;
if (!canonicalUrl || ticket.url === canonicalUrl) gmDelete(APP.captureResumeKey);
}
function consumeResumeCaptureForCurrentPage() {
if (state.handoff || state.settings?.capture?.cacheEnabled === false) return null;
const ticket = gmGet(APP.captureResumeKey, null);
if (!ticket || ticket.schema !== 'social-capture-resume/v1' || Number(ticket.expiresAt || 0) <= Date.now()) {
gmDelete(APP.captureResumeKey);
return null;
}
const site = state.site || currentSite();
const current = site ? canonicalize(site, location.href) : null;
if (!current || current !== ticket.url) return null;
const capture = getCachedCapture(current);
gmDelete(APP.captureResumeKey); // one-shot auto-resume; cache itself remains available.
return capture;
}
// ---------- URL builder registry ----------
function hasUrlCredentials(url) {
return Boolean(url?.username || url?.password);
}
function isLoopbackHost(hostname) {
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
return host === 'localhost'
|| host.endsWith('.localhost')
|| host === '::1'
|| /^127(?:\.\d{1,3}){3}$/.test(host);
}
function customUrlAllowed(url, allowInsecureHttp = false) {
if (!url || hasUrlCredentials(url)) return false;
if (url.protocol === 'https:') return true;
if (url.protocol !== 'http:') return false;
return isLoopbackHost(url.hostname) || Boolean(allowInsecureHttp);
}
function boundedString(value, max) {
return String(value ?? '').slice(0, max);
}
function templateProbeUrl(template) {
const probe = String(template || '').replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, 'x');
return parseUrl(probe, 'https://example.invalid/');
}
function normalizeCustomBuilder(raw, { allowInsecureHttp = false } = {}) {
return CORE.normalizeCustomBuilder(raw, { allowInsecureHttp });
}
function builderRegistry() {
const custom = state.settings?.builders?.custom || [];
return [...BUILTIN_BUILDERS, ...custom];
}
function compatibleBuilders(platform) {
return builderRegistry().filter((builder) => builder.platforms.includes(platform));
}
function builderById(id) {
return builderRegistry().find((builder) => builder.id === id) || null;
}
function selectedBuilder(site) {
const id = state.settings?.links?.[site.id]?.builderId;
const selected = builderById(id);
if (selected?.platforms.includes(site.id)) return selected;
return compatibleBuilders(site.id)[0] || null;
}
function builderVars(site, canonicalUrl) {
return CORE.builderVars(site?.id || site, canonicalUrl);
}
function applyTemplate(template, vars, { encode = false } = {}) {
return CORE.applyTemplate(template, vars, { encode });
}
function finalizeBuiltUrl(builder, raw) {
return CORE.finalizeBuiltUrl(builder, raw, {
allowInsecureHttp: state.settings?.security?.allowInsecureCustomUrls,
maxUrlChars: APP.maxBuilderUrlChars,
});
}
function buildUrl(builder, site, canonicalUrl) {
return CORE.buildUrl(builder, site?.id || site, canonicalUrl, {
allowInsecureHttp: state.settings?.security?.allowInsecureCustomUrls,
maxUrlChars: APP.maxBuilderUrlChars,
});
}
function transformedUrl(site, canonicalUrl) {
return buildUrl(selectedBuilder(site), site, canonicalUrl);
}
// ---------- Native Copy-link tracking sanitizer ----------
function sanitizeClipboardText(site, value) {
if (!site || typeof value !== 'string') return value;
if (!value || value.trim() !== value || /[\r\n]/.test(value)) return value;
return canonicalize(site, value) || value;
}
function installClipboardSanitizer() {
if (state.settings?.security?.sanitizeNativeCopy === false) return false;
if (state.clipboardPatch) return true;
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const clipboard = pageWindow?.navigator?.clipboard;
if (!clipboard || typeof clipboard.writeText !== 'function') return false;
const ownDescriptor = Object.getOwnPropertyDescriptor(clipboard, 'writeText');
const originalMethod = clipboard.writeText;
const wrapped = (value) => originalMethod.call(clipboard, sanitizeClipboardText(state.site || currentSite(), value));
try {
Object.defineProperty(clipboard, 'writeText', {
configurable: true,
writable: true,
value: wrapped,
});
state.clipboardPatch = { clipboard, originalMethod, wrapped, ownDescriptor };
return true;
} catch {
try {
clipboard.writeText = wrapped;
state.clipboardPatch = { clipboard, originalMethod, wrapped, ownDescriptor, assignmentFallback: true };
return true;
} catch {
return false;
}
}
}
function uninstallClipboardSanitizer() {
const patch = state.clipboardPatch;
if (!patch) return true;
try {
if (patch.clipboard?.writeText === patch.wrapped) {
if (patch.ownDescriptor) Object.defineProperty(patch.clipboard, 'writeText', patch.ownDescriptor);
else delete patch.clipboard.writeText;
}
} catch {
try {
if (patch.clipboard?.writeText === patch.wrapped) patch.clipboard.writeText = patch.originalMethod;
} catch {}
}
state.clipboardPatch = null;
return true;
}
function syncClipboardSanitizer() {
if (state.settings?.security?.sanitizeNativeCopy === false) return uninstallClipboardSanitizer();
return installClipboardSanitizer();
}
// ---------- Share trigger + post identity ----------
function accessibleLabels(target) {
if (!(target instanceof Element)) return [];
const control = target.closest('button, [role="button"], a') || target;
const values = [];
const push = (value) => {
const text = cleanText(value);
if (text) values.push(text);
};
push(control.getAttribute?.('aria-label'));
push(control.getAttribute?.('title'));
const labelledBy = control.getAttribute?.('aria-labelledby');
if (labelledBy) {
push(labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' '));
}
for (const element of queryAll(control, '[aria-label], [title], svg title')) {
push(element.getAttribute?.('aria-label'));
push(element.getAttribute?.('title'));
if (element.matches?.('svg title')) push(element.textContent);
if (values.length >= 12) break;
}
return uniqueValues(values);
}
function findShareTrigger(site, target) {
if (!(target instanceof Element)) return null;
for (const selector of site.shareSelectors || []) {
try {
const found = target.closest(selector);
if (found) return found;
} catch {}
}
const control = target.closest('button, [role="button"], a');
if (!control) return null;
const directLabels = [control.getAttribute?.('aria-label'), control.getAttribute?.('title')]
.map(normalizeLabel)
.filter(Boolean);
if (directLabels.some((label) => site.shareLabelSet.has(label))) return control;
// Most clicks never reach the semantic fallback. Only inspect descendants
// when the control actually exposes accessible labelling metadata.
let hasNestedLabel = false;
try { hasNestedLabel = Boolean(control.querySelector?.('[aria-label], [title], svg title')); } catch {}
if (!hasNestedLabel && !control.getAttribute?.('aria-labelledby')) return null;
if (accessibleLabels(control).some((label) => site.shareLabelSet.has(normalizeLabel(label)))) return control;
return null;
}
function findCanonicalLinkIn(site, root) {
if (!(root instanceof Element)) return null;
const seen = new Set();
for (const selector of site.postLinkSelectors || []) {
const links = [];
try { if (root.matches?.(selector)) links.push(root); } catch {}
links.push(...queryAll(root, selector));
for (const link of links) {
if (!link?.href || seen.has(link.href)) continue;
seen.add(link.href);
const canonical = canonicalize(site, link.href);
if (canonical) return canonical;
}
}
for (const link of queryAll(root, 'a[href]')) {
if (!link?.href || seen.has(link.href)) continue;
seen.add(link.href);
const canonical = canonicalize(site, link.href);
if (canonical) return canonical;
}
return null;
}
function rootHasSelector(root, selectors) {
if (!(root instanceof Element)) return false;
for (const selector of selectors || []) {
try {
if (root.matches?.(selector) || root.querySelector?.(selector)) return true;
} catch {}
}
return false;
}
function rootContainsCanonical(site, root, canonicalUrl) {
if (!(root instanceof Element)) return false;
for (const link of queryAll(root, 'a[href]')) {
if (canonicalize(site, link.href) === canonicalUrl) return true;
}
return false;
}
function closestBySelectors(element, selectors) {
if (!(element instanceof Element) || !selectors?.length) return null;
try {
return element.closest(selectors.join(','));
} catch {
for (const selector of selectors) {
try {
const found = element.closest(selector);
if (found) return found;
} catch {}
}
return null;
}
}
function isOwnedByPostRoot(site, root, element) {
if (!(root instanceof Element) || !(element instanceof Element)) return false;
if (element !== root && !root.contains(element)) return false;
const selectors = site.content.ownershipRootSelectors || site.content.rootSelectors || [];
const owner = closestBySelectors(element, selectors);
if (!owner) return true;
if (owner === root) return true;
// A nested post/quote owns its own body, media, metadata and controls.
// Do not let those descendants leak into the focal post extraction.
return false;
}
function ownedQueryAll(site, root, selector) {
return queryAll(root, selector).filter((element) => isOwnedByPostRoot(site, root, element));
}
function ownedQueryMany(site, root, selectors) {
const seen = new Set();
const out = [];
for (const selector of selectors || []) {
for (const element of ownedQueryAll(site, root, selector)) {
if (seen.has(element)) continue;
seen.add(element);
out.push(element);
}
}
return out;
}
function directNestedPostRoots(site, root) {
if (!(root instanceof Element)) return [];
const selectors = site.content.nestedPostRootSelectors || site.content.ownershipRootSelectors || [];
const candidates = queryMany(root, selectors).filter((candidate) => candidate !== root && root.contains(candidate));
return candidates.filter((candidate) => {
const parentOwner = closestBySelectors(candidate.parentElement, site.content.ownershipRootSelectors || selectors);
return parentOwner === root;
});
}
function resolveContentRoot(site, trigger, canonicalUrl, identityRoot = null) {
for (const selector of site.content.rootSelectors || []) {
const root = trigger.closest?.(selector);
if (root) return root;
}
const bodySelectors = [...(site.content.bodySelectors || []), ...(site.content.fallbackTextSelectors || [])];
let node = identityRoot || trigger;
while (node && node !== document.body && node !== document.documentElement) {
if (rootHasSelector(node, bodySelectors) && rootContainsCanonical(site, node, canonicalUrl)) return node;
node = node.parentElement;
}
return identityRoot || trigger;
}
function resolvePostContext(site, trigger) {
for (const selector of site.identityScopeSelectors || []) {
const scope = trigger.closest?.(selector);
if (!scope) continue;
const url = findCanonicalLinkIn(site, scope);
if (url) return { url, root: resolveContentRoot(site, trigger, url, scope) };
}
for (const selector of site.content.rootSelectors || []) {
const root = trigger.closest?.(selector);
if (!root) continue;
const url = findCanonicalLinkIn(site, root);
if (url) return { url, root };
}
let node = trigger;
while (node && node !== document.body && node !== document.documentElement) {
const url = findCanonicalLinkIn(site, node);
if (url) return { url, root: resolveContentRoot(site, trigger, url, node) };
node = node.parentElement;
}
const pageUrl = canonicalize(site, location.href);
return pageUrl ? { url: pageUrl, root: resolveContentRoot(site, trigger, pageUrl) } : null;
}
// ---------- SocialCapture extraction ----------
function isExcludedBodyNode(root, element, config) {
if (!(element instanceof Element)) return true;
for (const selector of config.bodyExcludeSelectors || []) {
try { if (element.matches(selector)) return true; } catch {}
}
for (const selector of config.bodyExcludeAncestorSelectors || []) {
try {
const ancestor = element.closest(selector);
if (ancestor && ancestor !== root && root.contains(ancestor)) return true;
} catch {}
}
for (const selector of config.bodyExcludeDescendantSelectors || []) {
try { if (element.querySelector(selector)) return true; } catch {}
}
return false;
}
function textFromSelectors(site, root, selectors, config) {
if (!(root instanceof Element)) return [];
const values = [];
for (const selector of selectors || []) {
for (const element of ownedQueryAll(site, root, selector)) {
if (!isVisible(element) || isExcludedBodyNode(root, element, config)) continue;
const text = cleanText(element.innerText || element.textContent);
if (text) values.push(text);
}
if (values.length) break;
}
return uniqueValues(values);
}
function plausibleBody(text, authorHandle) {
const value = cleanText(text);
if (!value) return false;
const bare = String(authorHandle || '').replace(/^@/, '').toLocaleLowerCase();
const lowered = value.toLocaleLowerCase();
if (bare && [bare, `@${bare}`].includes(lowered)) return false;
if (/^[\d.,+\s]*(?:k|m|b|萬|万|千|億|亿)?$/iu.test(value)) return false;
if (/^\d+\s*(?:s|m|h|d|w|y|min|hr|day|week|year)s?$/iu.test(value)) return false;
return true;
}
function extractTimestamp(root, site) {
for (const selector of site.content.timestampSelectors || []) {
for (const element of ownedQueryAll(site, root, selector)) {
const value = element.getAttribute?.('datetime') || element.dateTime || '';
if (value) return value;
}
}
return null;
}
function extractDisplayName(site, root, authorHandle) {
const bare = String(authorHandle || '').replace(/^@/, '').toLocaleLowerCase();
if (site.id === 'x') {
for (const container of ownedQueryMany(site, root, site.content.authorContainerSelectors)) {
const texts = ownedQueryAll(site, root, 'span').filter((el) => container.contains(el)).map((el) => cleanText(el.textContent)).filter(Boolean);
const display = texts.find((text) => !text.startsWith('@') && text.toLocaleLowerCase() !== bare && !/^·$/.test(text));
if (display) return display;
}
}
if (site.id === 'threads' && bare) {
for (const link of ownedQueryAll(site, root, 'a[href^="/@"], a[href^="/"]')) {
const href = (parseUrl(link.href)?.pathname || '').toLocaleLowerCase();
if (href !== `/@${bare}` && href !== `/${bare}`) continue;
const text = cleanText(link.textContent);
if (text && text.toLocaleLowerCase() !== bare && text.toLocaleLowerCase() !== `@${bare}`) return text;
}
}
return null;
}
function extractMedia(site, root) {
const media = [];
const seen = new Set();
for (const image of ownedQueryMany(site, root, site.content.mediaImageSelectors)) {
const src = image.currentSrc || image.src || '';
if (!src || seen.has(src)) continue;
seen.add(src);
media.push({
type: 'image',
url: src,
alt: cleanText(image.getAttribute('alt')) || null,
width: image.naturalWidth || Number(image.getAttribute('width')) || null,
height: image.naturalHeight || Number(image.getAttribute('height')) || null,
});
}
for (const video of ownedQueryMany(site, root, site.content.mediaVideoSelectors)) {
const src = video.currentSrc || video.src || '';
const poster = video.poster || '';
const key = src || poster;
if (!key || seen.has(key)) continue;
seen.add(key);
media.push({
type: 'video',
url: src || null,
previewUrl: poster || null,
alt: null,
width: video.videoWidth || null,
height: video.videoHeight || null,
});
}
return media;
}
function parseCompactNumber(raw) {
const text = String(raw || '').replace(/,/g, '').trim().toLocaleLowerCase();
const match = text.match(/(\d+(?:\.\d+)?)\s*([kmb])?/i);
if (!match) return null;
const factor = match[2] === 'k' ? 1e3 : match[2] === 'm' ? 1e6 : match[2] === 'b' ? 1e9 : 1;
return Math.round(Number(match[1]) * factor);
}
function extractMetrics(site, root) {
if (!state.settings.capture.includeMetrics) return {};
const metrics = {};
if (site.id === 'x') {
const defs = [
['replies', '[data-testid="reply"]'],
['reposts', '[data-testid="retweet"]'],
['likes', '[data-testid="like"]'],
];
for (const [key, selector] of defs) {
const element = ownedQueryAll(site, root, selector)[0];
const count = parseCompactNumber(element?.getAttribute?.('aria-label') || '');
if (count != null) metrics[key] = count;
}
const analytics = ownedQueryAll(site, root, 'a[href*="/analytics"]')[0];
const views = parseCompactNumber(analytics?.getAttribute?.('aria-label') || analytics?.textContent || '');
if (views != null) metrics.views = views;
}
if (site.id === 'threads') {
const map = { Like: 'likes', Comment: 'replies', Repost: 'reposts' };
for (const [label, key] of Object.entries(map)) {
const icon = ownedQueryAll(site, root, `svg[aria-label="${label}"]`)[0];
const control = icon?.closest?.('[role="button"], button');
const count = parseCompactNumber(control?.innerText || control?.textContent || '');
if (count != null) metrics[key] = count;
}
}
return metrics;
}
function actorFromProfileLink(site, link) {
const url = parseUrl(link?.href);
if (!url || !site.hostSet.has(url.hostname.toLowerCase())) return null;
const parts = url.pathname.split('/').filter(Boolean);
if (!parts.length) return null;
const first = decodeURIComponent(parts[0]).replace(/^@/, '');
if (!first || ['home', 'explore', 'search', 'i', 'messages', 'notifications'].includes(first.toLocaleLowerCase())) return null;
return { handle: `@${first}` };
}
function extractRepostContext(site, root) {
for (const selector of site.content.repostContextSelectors || []) {
for (const element of ownedQueryAll(site, root, selector)) {
const label = cleanText(element.innerText || element.textContent);
if (!label) continue;
let actor = null;
for (const actorSelector of site.content.repostActorLinkSelectors || []) {
const link = ownedQueryAll(site, root, actorSelector).find((candidate) => element.contains(candidate));
actor = actorFromProfileLink(site, link);
if (actor) break;
}
return { label, actor };
}
}
// Conservative fallback: only inspect short, focal-owned lines near the top
// of a post card. Do not classify quote text or arbitrary body text.
const candidates = ownedQueryAll(site, root, '[dir="auto"], span')
.slice(0, 30)
.map((el) => cleanText(el.textContent))
.filter((text) => text && text.length < 120);
const label = candidates.find((text) => /\b(reposted|repost by|reposted by)\b/i.test(text));
return label ? { label, actor: null } : null;
}
function extractPostNode(site, root, canonicalUrl) {
if (!(root instanceof Element) || !canonicalUrl) return null;
const parts = postParts(site, canonicalUrl);
const handle = parts.author ? `@${parts.author}` : null;
let bodies = textFromSelectors(site, root, site.content.bodySelectors, site.content).filter((text) => plausibleBody(text, handle));
if (!bodies.length) {
bodies = textFromSelectors(site, root, site.content.fallbackTextSelectors, site.content).filter((text) => plausibleBody(text, handle));
}
return {
id: `${site.id}:${parts.postId || canonicalUrl}`,
platform: site.id,
url: canonicalUrl,
author: {
handle,
displayName: extractDisplayName(site, root, handle),
},
text: bodies[0] || null,
publishedAt: extractTimestamp(root, site),
media: state.settings.capture.includeMedia ? extractMedia(site, root) : [],
metrics: extractMetrics(site, root),
};
}
function candidateNestedRoots(site, root) {
return directNestedPostRoots(site, root);
}
function extractEmbeddedContext(site, focalRoot, focalUrl) {
const items = [];
const seen = new Set([focalUrl]);
for (const candidate of candidateNestedRoots(site, focalRoot)) {
const url = findCanonicalLinkIn(site, candidate);
if (!url || seen.has(url)) continue;
const post = extractPostNode(site, candidate, url);
if (!post?.text && !post?.media?.length) continue;
seen.add(url);
items.push({ relation: 'quotes', confidence: 'nested-post-root', post });
if (items.length >= 3) break;
}
return items;
}
function reportedReplyCount(focal) {
return Number.isFinite(focal?.metrics?.replies) ? focal.metrics.replies : null;
}
function isTopLevelDiscussionRoot(site, main, root) {
const selectors = site.content.ownershipRootSelectors || site.content.discussionRootSelectors || [];
const owner = closestBySelectors(root.parentElement, selectors);
return !owner || !main.contains(owner);
}
function collectVisibleDiscussion(site, context, limit) {
if (!limit) return [];
const pageCanonical = canonicalize(site, location.href);
if (!pageCanonical || pageCanonical !== context.url) return [];
const main = context.root.closest?.('main') || document.querySelector('main') || document.body;
const roots = queryMany(main, site.content.discussionRootSelectors || site.content.rootSelectors);
const replies = [];
const seen = new Set([context.url]);
for (const root of roots) {
if (root === context.root || context.root.contains(root)) continue;
if (!isTopLevelDiscussionRoot(site, main, root)) continue;
const position = context.root.compareDocumentPosition(root);
if (!(position & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
const url = findCanonicalLinkIn(site, root);
if (!url || seen.has(url)) continue;
const post = extractPostNode(site, root, url);
if (!post?.text && !post?.media?.length) continue;
seen.add(url);
replies.push(post);
if (replies.length >= limit) break;
}
return replies;
}
function canCaptureVisibleDiscussion(context) {
return canonicalize(context.site, location.href) === context.url;
}
function buildSocialCapture(context, mode = 'smart') {
const focal = extractPostNode(context.site, context.root, context.url);
if (!focal) return null;
const includeContext = mode !== 'post';
const embedded = includeContext ? extractEmbeddedContext(context.site, context.root, context.url) : [];
const repost = includeContext ? extractRepostContext(context.site, context.root) : null;
const includeDiscussion = mode === 'discussion';
const visibleReplies = includeDiscussion
? collectVisibleDiscussion(context.site, context, state.settings.capture.maxVisibleReplies)
: [];
const relations = embedded.map((item) => ({
type: item.relation,
from: focal.id,
to: item.post.id,
confidence: item.confidence || 'dom',
}));
const capturedAt = new Date().toISOString();
const capture = {
schema: 'social-capture/v1',
capturedAt,
snapshot: {
source: 'live-dom',
dynamicFields: ['metrics', 'media-urls'],
note: 'Metrics and remote media references may change after capture.',
fingerprintAlgorithm: 'fnv1a64-noncrypto',
fingerprint: null,
},
platform: context.site.id,
platformName: context.site.platformName,
captureMode: mode,
focal,
relations,
context: {
repost,
embedded,
},
discussion: {
scope: includeDiscussion ? 'visible-following-posts' : 'not-captured',
reportedReplyCount: reportedReplyCount(focal),
capturedReplyCount: visibleReplies.length,
complete: false,
posts: visibleReplies,
},
};
capture.snapshot.fingerprint = snapshotFingerprint(capture);
return capture;
}
function captureContextSummary(capture) {
if (!capture) return '';
const parts = [];
if (capture.context.repost) parts.push('repost');
if (capture.context.embedded.length) parts.push(`${capture.context.embedded.length} quote${capture.context.embedded.length === 1 ? '' : 's'}`);
if (capture.focal.media.length) parts.push(`${capture.focal.media.length} media`);
return parts.join(', ');
}
function captureStatusSummary(capture) {
if (!capture) return '';
const parts = [];
const context = captureContextSummary(capture);
if (context) parts.push(context);
if (capture.discussion.capturedReplyCount) parts.push(`${capture.discussion.capturedReplyCount} replies`);
return parts.join(', ');
}
// ---------- SocialCapture renderers ----------
function safeRenderableUrl(raw) {
const url = validHttpUrl(raw);
return url && !hasUrlCredentials(url) ? url.href : null;
}
function renderPostText(post, heading = 'Post') {
const lines = [`## ${heading}`, ''];
const author = [post.author.displayName, post.author.handle].filter(Boolean).join(' ');
if (author) lines.push(`Author: ${author}`);
lines.push(`Source: ${post.url}`);
if (post.publishedAt) lines.push(`Published: ${post.publishedAt}`);
if (Object.keys(post.metrics || {}).length) {
const metrics = Object.entries(post.metrics).map(([key, value]) => `${key}=${value}`).join(', ');
lines.push(`Metrics: ${metrics}`);
}
if (post.text) {
lines.push('', ...post.text.split('\n').map((line) => `> ${line}`));
}
if (post.media?.length) {
lines.push('', `### Media (${post.media.length})`, '');
post.media.forEach((media, index) => {
lines.push(`${index + 1}. ${media.type}`);
if (media.url) lines.push(` URL: ${media.url}`);
if (media.previewUrl) lines.push(` Preview: ${media.previewUrl}`);
if (media.alt) lines.push(` Alt: ${media.alt}`);
if (media.width && media.height) lines.push(` Size: ${media.width}×${media.height}`);
});
}
return lines.join('\n');
}
function renderCaptureText(capture) {
const lines = [
'Social post capture',
`Schema: ${capture.schema}`,
`Platform: ${capture.platformName}`,
`Capture mode: ${capture.captureMode}`,
`Captured at: ${capture.capturedAt}`,
`Snapshot fingerprint: ${capture.snapshot?.fingerprint || 'unavailable'} (non-cryptographic change marker)`,
'Snapshot note: metrics and remote media references may have changed since capture.',
'',
renderPostText(capture.focal, 'Focal post'),
];
if (capture.context.repost) {
lines.push('', '## Repost context', '', capture.context.repost.label);
if (capture.context.repost.actor?.handle) lines.push(`Actor: ${capture.context.repost.actor.handle}`);
}
for (const [index, item] of capture.context.embedded.entries()) {
lines.push('', renderPostText(item.post, `Quoted / embedded post ${index + 1}`));
}
if (capture.discussion.posts.length) {
lines.push(
'',
'## Visible discussion',
'',
`Captured replies: ${capture.discussion.capturedReplyCount}`,
`Reported reply count: ${capture.discussion.reportedReplyCount ?? 'unknown'}`,
'Discussion complete: no',
);
capture.discussion.posts.forEach((post, index) => {
lines.push('', renderPostText(post, `Visible reply ${index + 1}`));
});
} else if (capture.captureMode === 'discussion') {
lines.push(
'',
'## Visible discussion',
'',
'Captured replies: 0',
`Reported reply count: ${capture.discussion.reportedReplyCount ?? 'unknown'}`,
'Discussion complete: no',
);
} else if (capture.discussion.reportedReplyCount != null) {
lines.push('', `Reported replies: ${capture.discussion.reportedReplyCount}`, 'Visible discussion captured: no');
}
return `${lines.join('\n').trim()}\n`;
}
function renderPostHtml(post, heading = 'Post') {
const author = [post.author.displayName, post.author.handle].filter(Boolean).map(escapeHtml).join(' ');
const metrics = Object.entries(post.metrics || {}).map(([key, value]) => `${escapeHtml(key)}=${escapeHtml(value)}`).join(', ');
const media = (post.media || []).map((item) => {
const mediaUrl = safeRenderableUrl(item.url);
const previewUrl = safeRenderableUrl(item.previewUrl);
const src = mediaUrl || previewUrl;
// Remote images are opt-in: pasting rich HTML with
// can trigger an automatic network request in the destination editor.
const image = state.settings.capture.richHtmlImages && item.type === 'image' && src
? `
Author: ${author}
` : ''}Source: ${sourceUrl ? `${escapeHtml(post.url)}` : escapeHtml(post.url)}
${post.publishedAt ? `Published: ${escapeHtml(post.publishedAt)}
` : ''} ${metrics ? `Metrics: ${metrics}
` : ''} ${post.text ? `${escapeHtml(post.text).replace(/\n/g, '` : ''} ${media ? `
')}
Platform: ${escapeHtml(capture.platformName)}
Capture mode: ${escapeHtml(capture.captureMode)}
Captured at: ${escapeHtml(capture.capturedAt)}
Snapshot: metrics and remote media references may have changed since capture.
${escapeHtml(capture.context.repost.label)}${capture.context.repost.actor?.handle ? `
Actor: ${escapeHtml(capture.context.repost.actor.handle)}` : ''}
Captured replies: ${capture.discussion.capturedReplyCount}
Reported reply count: ${escapeHtml(capture.discussion.reportedReplyCount ?? 'unknown')}
Discussion complete: no
Captured replies: 0
Reported reply count: ${escapeHtml(capture.discussion.reportedReplyCount ?? 'unknown')}
Discussion complete: no