Share funktion
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Image } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import type { ShareIntent, ShareIntentFile } from 'expo-share-intent';
|
||||
@@ -16,14 +17,40 @@ 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;
|
||||
// Profilbilder (150x150), Logos und Tracking-Pixel aussortieren, die die Score-Heuristik durchrutschen.
|
||||
const SHARE_IMAGE_MIN_DIMENSION = 320;
|
||||
const LOGIN_WALL_PATTERN = /accounts\/login|LoginAndSignupPage|loginForm|not-logged-in/i;
|
||||
|
||||
export type SharedImageFailureReason = 'login_wall' | 'no_image';
|
||||
|
||||
export type SharedImageResolution = {
|
||||
uri: string;
|
||||
requiresConfirmation: boolean;
|
||||
};
|
||||
|
||||
export type SharedImageResolutionFailure = {
|
||||
failureReason: SharedImageFailureReason;
|
||||
};
|
||||
|
||||
export type SharedImageResolutionResult = SharedImageResolution | SharedImageResolutionFailure;
|
||||
|
||||
export const isSharedImageResolutionFailure = (
|
||||
result: SharedImageResolutionResult,
|
||||
): result is SharedImageResolutionFailure => 'failureReason' in result;
|
||||
|
||||
export const detectLoginWall = (html: string): boolean => LOGIN_WALL_PATTERN.test(html);
|
||||
|
||||
// URLs aus HTML-Attributen tragen Entities (&) — undecodiert bricht die
|
||||
// CDN-Signatur (z. B. Instagrams oh=/oe=-Parameter) und der Download liefert 403.
|
||||
const decodeHtmlEntities = (value: string): string => value
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/�*38;/g, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�*39;/g, "'")
|
||||
.replace(/'/gi, "'");
|
||||
|
||||
const normalizeSharedImageUri = (uri: string, baseUrl?: string | null): string | null => {
|
||||
const trimmed = uri.trim();
|
||||
const trimmed = decodeHtmlEntities(uri.trim());
|
||||
if (!trimmed) return null;
|
||||
if (/^(data:image|file:|https?:\/\/)/i.test(trimmed)) return trimmed;
|
||||
if (!baseUrl) return null;
|
||||
@@ -149,8 +176,18 @@ export const getSharedImageUri = (shareIntent: ShareIntent): string | null => {
|
||||
return candidate ?? null;
|
||||
};
|
||||
|
||||
const meetsMinimumDimensions = (uri: string): Promise<boolean> => new Promise((resolve) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => resolve(Math.min(width, height) >= SHARE_IMAGE_MIN_DIMENSION),
|
||||
() => resolve(false),
|
||||
);
|
||||
});
|
||||
|
||||
const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string): Promise<string | null> => {
|
||||
if (/^data:image/i.test(imageUrl)) return imageUrl;
|
||||
if (/^data:image/i.test(imageUrl)) {
|
||||
return (await meetsMinimumDimensions(imageUrl)) ? imageUrl : null;
|
||||
}
|
||||
if (/^file:/i.test(imageUrl)) return imageUrl;
|
||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||
|
||||
@@ -176,6 +213,12 @@ const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string):
|
||||
return null;
|
||||
}
|
||||
|
||||
const meetsMinimumSize = await meetsMinimumDimensions(download.uri);
|
||||
if (!meetsMinimumSize) {
|
||||
FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await ImageManipulator.manipulateAsync(
|
||||
download.uri,
|
||||
@@ -196,9 +239,14 @@ const extractSrcsetUrls = (srcset: string): string[] => srcset
|
||||
.filter(Boolean)
|
||||
.reverse();
|
||||
|
||||
const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => {
|
||||
export const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => {
|
||||
const candidates: string[] = [];
|
||||
const add = (value: string | undefined) => addUniqueCandidate(candidates, value, baseUrl);
|
||||
const add = (value: string | undefined) => {
|
||||
// Inline-Base64-Bilder im HTML sind praktisch immer Lade-Spinner/Platzhalter —
|
||||
// die würden downloadAndValidateImage ungeprüft passieren.
|
||||
if (value && /^data:/i.test(value.trim())) return;
|
||||
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,
|
||||
@@ -237,7 +285,7 @@ const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] =>
|
||||
return sortImageCandidates(candidates);
|
||||
};
|
||||
|
||||
export async function fetchOgImageFromUrl(url: string): Promise<string | null> {
|
||||
export async function fetchOgImageFromUrl(url: string): Promise<{ uri: string } | SharedImageResolutionFailure> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
@@ -249,23 +297,25 @@ export async function fetchOgImageFromUrl(url: string): Promise<string | null> {
|
||||
'User-Agent': FETCH_USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok) return { failureReason: 'no_image' };
|
||||
|
||||
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;
|
||||
if (validated) return { uri: validated };
|
||||
}
|
||||
|
||||
return null;
|
||||
// Login-Seiten enthalten oft Login-Marker UND brauchbare OG-Tags; deshalb erst
|
||||
// nach erfolglosem Kandidaten-Durchlauf als Login-Wall werten.
|
||||
return { failureReason: detectLoginWall(html) ? 'login_wall' : 'no_image' };
|
||||
} catch {
|
||||
return null;
|
||||
return { failureReason: 'no_image' };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<SharedImageResolution | null> => {
|
||||
export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<SharedImageResolutionResult> => {
|
||||
const directFileUri = getDirectSharedImageFileUri(shareIntent.files);
|
||||
if (directFileUri) {
|
||||
return { uri: directFileUri, requiresConfirmation: false };
|
||||
@@ -273,7 +323,16 @@ export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<S
|
||||
|
||||
const refererUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || undefined;
|
||||
for (const candidate of getSharedImageCandidates(shareIntent).slice(0, 16)) {
|
||||
if (/^(data:image|file:)/i.test(candidate)) {
|
||||
if (/^data:image/i.test(candidate)) {
|
||||
// Die Share-Extension sammelt alle <img>-Quellen ein — darunter Base64-Spinner.
|
||||
// Nur ausreichend große Inline-Bilder akzeptieren, und wegen der Unsicherheit
|
||||
// der Quelle immer die Bestätigungs-Vorschau zeigen.
|
||||
if (await meetsMinimumDimensions(candidate)) {
|
||||
return { uri: candidate, requiresConfirmation: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/^file:/i.test(candidate)) {
|
||||
return { uri: candidate, requiresConfirmation: false };
|
||||
}
|
||||
|
||||
@@ -284,13 +343,14 @@ export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<S
|
||||
}
|
||||
|
||||
if (refererUrl) {
|
||||
const fetchedUri = await fetchOgImageFromUrl(refererUrl);
|
||||
if (fetchedUri) {
|
||||
return { uri: fetchedUri, requiresConfirmation: true };
|
||||
const fetched = await fetchOgImageFromUrl(refererUrl);
|
||||
if ('uri' in fetched) {
|
||||
return { uri: fetched.uri, requiresConfirmation: true };
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
return null;
|
||||
return { failureReason: 'no_image' };
|
||||
};
|
||||
|
||||
export const summarizeShareIntent = (shareIntent: ShareIntent) => ({
|
||||
|
||||
Reference in New Issue
Block a user