Files
Greenlens/utils/shareIntent.ts
2026-05-18 15:59:46 +02:00

304 lines
11 KiB
TypeScript

import * as FileSystem from 'expo-file-system/legacy';
import * as ImageManipulator from 'expo-image-manipulator';
import type { ShareIntent, ShareIntentFile } from 'expo-share-intent';
const IMAGE_META_KEYS = [
'og:image',
'og:image:url',
'og:image:secure_url',
'twitter:image',
'twitter:image:src',
'image',
];
const IMAGE_URL_PATTERN = /\.(?:avif|gif|heic|heif|jpe?g|png|webp)(?:$|[?#])/i;
const IMAGE_FORMAT_QUERY_PATTERN = /[?&](?:format|fm|auto)=(?:avif|gif|heic|heif|jpe?g|png|webp)(?:&|$)/i;
const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi;
const FETCH_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const SHARE_IMAGE_MAX_WIDTH = 1280;
const SHARE_IMAGE_JPEG_QUALITY = 0.9;
export type SharedImageResolution = {
uri: string;
requiresConfirmation: boolean;
};
const normalizeSharedImageUri = (uri: string, baseUrl?: string | null): string | null => {
const trimmed = uri.trim();
if (!trimmed) return null;
if (/^(data:image|file:|https?:\/\/)/i.test(trimmed)) return trimmed;
if (!baseUrl) return null;
try {
return new URL(trimmed, baseUrl).toString();
} catch {
return null;
}
};
const isLikelyImageResourceUri = (uri: string): boolean => {
if (/^(data:image|file:)/i.test(uri)) return true;
return IMAGE_URL_PATTERN.test(uri) || IMAGE_FORMAT_QUERY_PATTERN.test(uri);
};
const extractDirectImageUri = (value: string | null | undefined): string | null => {
if (!value) return null;
const normalizedValue = normalizeSharedImageUri(value);
if (normalizedValue && isLikelyImageResourceUri(normalizedValue)) {
return normalizedValue;
}
const urls = value.match(URL_PATTERN) || [];
for (const url of urls) {
const normalizedUri = normalizeSharedImageUri(url);
if (normalizedUri && isLikelyImageResourceUri(normalizedUri)) {
return normalizedUri;
}
}
return null;
};
const extractImageCandidateList = (value: string | null | undefined): string[] => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string');
}
} catch {
return [];
}
return [];
};
const getDirectSharedImageFileUri = (files: ShareIntentFile[] | null | undefined): string | null => {
const sharedImage = files?.find((file) => file.path && file.mimeType?.startsWith('image/'));
if (sharedImage?.path) return sharedImage.path;
const imageLikeFile = files?.find((file) => file.path && isLikelyImageResourceUri(file.path));
return imageLikeFile?.path ?? null;
};
const addUniqueCandidate = (candidates: string[], value: string | null | undefined, baseUrl?: string | null) => {
if (!value) return;
const normalizedUri = normalizeSharedImageUri(value, baseUrl);
if (normalizedUri && !candidates.includes(normalizedUri)) {
candidates.push(normalizedUri);
}
};
export const extractUrlFromText = (text: string | null | undefined): string | null => {
if (!text) return null;
const match = text.match(/https?:\/\/[^\s"'<>]+/i);
return match?.[0]?.replace(/[).,!?]+$/g, '') ?? null;
};
const scoreImageCandidate = (uri: string): number => {
const lower = uri.toLowerCase();
let score = 0;
if (/og:image|twitter:image|display_url|thumbnail_src/.test(lower)) score += 20;
if (IMAGE_URL_PATTERN.test(uri) || IMAGE_FORMAT_QUERY_PATTERN.test(uri)) score += 12;
if (/(?:^|[?&])(w|width|maxwidth|resize|s)=([1-9]\d{2,4})/i.test(uri)) {
const width = Number(uri.match(/(?:^|[?&])(?:w|width|maxwidth|resize|s)=([1-9]\d{2,4})/i)?.[1] ?? 0);
score += Math.min(width / 20, 80);
}
for (const size of uri.match(/\b([1-9]\d{2,4})x([1-9]\d{2,4})\b/g) ?? []) {
const [width, height] = size.split('x').map(Number);
score += Math.min((width * height) / 20000, 90);
}
if (/\/media\/|\/photos?\/|\/image\/|\/images\//.test(lower)) score += 10;
if (/logo|avatar|icon|sprite|placeholder|blank|tracking|pixel|loader|spinner/.test(lower)) score -= 80;
if (/profile_pic|s150x150|150x150|100x100|64x64|32x32/.test(lower)) score -= 60;
return score;
};
const sortImageCandidates = (candidates: string[]): string[] => [...candidates].sort(
(a, b) => scoreImageCandidate(b) - scoreImageCandidate(a),
);
export const getSharedImageCandidates = (shareIntent: ShareIntent): string[] => {
const candidates: string[] = [];
const baseUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || shareIntent.text;
for (const key of IMAGE_META_KEYS) {
const value = shareIntent.meta?.[key];
if (typeof value === 'string') {
addUniqueCandidate(candidates, value, baseUrl);
}
}
for (const candidate of extractImageCandidateList(shareIntent.meta?.['greenlens:imageCandidates'])) {
addUniqueCandidate(candidates, candidate, baseUrl);
}
addUniqueCandidate(candidates, extractDirectImageUri(shareIntent.webUrl));
addUniqueCandidate(candidates, extractDirectImageUri(shareIntent.text));
return sortImageCandidates(candidates);
};
export const getSharedImageUri = (shareIntent: ShareIntent): string | null => {
const directFileUri = getDirectSharedImageFileUri(shareIntent.files);
if (directFileUri) return directFileUri;
const [candidate] = getSharedImageCandidates(shareIntent);
return candidate ?? null;
};
const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string): Promise<string | null> => {
if (/^data:image/i.test(imageUrl)) return imageUrl;
if (/^file:/i.test(imageUrl)) return imageUrl;
if (!/^https?:\/\//i.test(imageUrl)) return null;
const ext = imageUrl.match(/\.(jpe?g|png|webp|gif)(?:$|[?#])/i)?.[1] ?? 'jpg';
const cacheUri = `${FileSystem.cacheDirectory}share-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const download = await FileSystem.downloadAsync(imageUrl, cacheUri, {
headers: {
...(refererUrl ? { Referer: refererUrl } : {}),
'User-Agent': FETCH_USER_AGENT,
Accept: 'image/jpeg,image/png,image/webp,image/gif,*/*;q=0.8',
},
});
if (!(download.status >= 200 && download.status < 300)) return null;
const contentType = (
(download.headers as Record<string, string>)?.['content-type']
?? (download.headers as Record<string, string>)?.['Content-Type']
?? ''
).toLowerCase();
if (contentType.startsWith('text/') || /json|html|xml|avif|heic|heif/.test(contentType)) {
FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {});
return null;
}
try {
const processed = await ImageManipulator.manipulateAsync(
download.uri,
[{ resize: { width: SHARE_IMAGE_MAX_WIDTH } }],
{ compress: SHARE_IMAGE_JPEG_QUALITY, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return processed.base64 ? `data:image/jpeg;base64,${processed.base64}` : null;
} catch {
return null;
} finally {
FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {});
}
};
const extractSrcsetUrls = (srcset: string): string[] => srcset
.split(',')
.map((item) => item.trim().split(/\s+/)[0])
.filter(Boolean)
.reverse();
const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => {
const candidates: string[] = [];
const add = (value: string | undefined) => addUniqueCandidate(candidates, value, baseUrl);
const metaPatterns = [
/<meta\s+(?:[^>]*?\s+)?property=["']og:image:secure_url["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image:secure_url["']/gi,
/<meta\s+(?:[^>]*?\s+)?property=["']og:image["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image["']/gi,
/<meta\s+(?:[^>]*?\s+)?name=["']twitter:image(?::src)?["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+name=["']twitter:image(?::src)?["']/gi,
/<link\s+(?:[^>]*?\s+)?rel=["']image_src["'][^>]*\s+href=["']([^"']+)["']/gi,
];
for (const pattern of metaPatterns) {
for (const match of html.matchAll(pattern)) add(match[1]);
}
const imageAttributePattern = /<(?:img|source)\b[^>]*(?:src|data-src|data-original|data-lazy-src)=["']([^"']+)["'][^>]*>/gi;
for (const match of html.matchAll(imageAttributePattern)) add(match[1]);
const srcsetPattern = /<(?:img|source)\b[^>]*srcset=["']([^"']+)["'][^>]*>/gi;
for (const match of html.matchAll(srcsetPattern)) {
for (const srcsetUrl of extractSrcsetUrls(match[1])) add(srcsetUrl);
}
const posterPattern = /<video\b[^>]*poster=["']([^"']+)["'][^>]*>/gi;
for (const match of html.matchAll(posterPattern)) add(match[1]);
const jsonImagePatterns = [
/"(?:display_url|thumbnail_src|contentUrl|image)"\s*:\s*"([^"]+)"/gi,
/"url"\s*:\s*"([^"]+\.(?:jpe?g|png|webp|gif)(?:\\?[^"]*)?)"/gi,
];
for (const pattern of jsonImagePatterns) {
for (const match of html.matchAll(pattern)) {
add(match[1]?.replace(/\\u0026/g, '&').replace(/\\/g, ''));
}
}
return sortImageCandidates(candidates);
};
export async function fetchOgImageFromUrl(url: string): Promise<string | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'User-Agent': FETCH_USER_AGENT,
},
});
if (!response.ok) return null;
const html = await response.text();
for (const candidate of extractHtmlImageCandidates(html, url).slice(0, 16)) {
const validated = await downloadAndValidateImage(candidate, url);
if (validated) return validated;
}
return null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<SharedImageResolution | null> => {
const directFileUri = getDirectSharedImageFileUri(shareIntent.files);
if (directFileUri) {
return { uri: directFileUri, requiresConfirmation: false };
}
const refererUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || undefined;
for (const candidate of getSharedImageCandidates(shareIntent).slice(0, 16)) {
if (/^(data:image|file:)/i.test(candidate)) {
return { uri: candidate, requiresConfirmation: false };
}
const validated = await downloadAndValidateImage(candidate, refererUrl);
if (validated) {
return { uri: validated, requiresConfirmation: true };
}
}
if (refererUrl) {
const fetchedUri = await fetchOgImageFromUrl(refererUrl);
if (fetchedUri) {
return { uri: fetchedUri, requiresConfirmation: true };
}
}
return null;
};
export const summarizeShareIntent = (shareIntent: ShareIntent) => ({
type: shareIntent.type,
fileCount: shareIntent.files?.length ?? 0,
fileMimeTypes: shareIntent.files?.map((file) => file.mimeType),
webUrl: shareIntent.webUrl,
text: shareIntent.text,
metaKeys: shareIntent.meta ? Object.keys(shareIntent.meta) : [],
});