SEO
This commit is contained in:
303
utils/shareIntent.ts
Normal file
303
utils/shareIntent.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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) : [],
|
||||
});
|
||||
Reference in New Issue
Block a user