Share funktion

This commit is contained in:
2026-07-16 17:13:37 +02:00
parent 52c9dfb472
commit c7c1864fc6
9 changed files with 311 additions and 27 deletions

View File

@@ -0,0 +1,82 @@
import { detectLoginWall, extractHtmlImageCandidates, fetchOgImageFromUrl } from '../../utils/shareIntent';
describe('shareIntent login wall handling', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
const mockFetchHtml = (html: string, ok = true) => {
global.fetch = jest.fn().mockResolvedValue({
ok,
text: async () => html,
}) as unknown as typeof fetch;
};
describe('detectLoginWall', () => {
it('detects Instagram login wall markers', () => {
expect(detectLoginWall('<a href="/accounts/login/?next=%2Fp%2Fabc">Log in</a>')).toBe(true);
expect(detectLoginWall('{"page":"LoginAndSignupPage"}')).toBe(true);
expect(detectLoginWall('<form id="loginForm">')).toBe(true);
expect(detectLoginWall('<body class="not-logged-in">')).toBe(true);
});
it('does not flag regular post pages', () => {
expect(detectLoginWall('<meta property="og:image" content="https://cdn.example.com/post.jpg" />')).toBe(false);
});
});
describe('extractHtmlImageCandidates', () => {
const baseUrl = 'https://www.instagram.com/p/abc/';
it('decodes HTML entities in og:image URLs so signed CDN params survive', () => {
const html = '<meta property="og:image" content="https://scontent.cdninstagram.com/v/t51/img.jpg?stp=dst-jpg_s640x640&amp;_nc_ohc=token&amp;oh=hash&amp;oe=expiry" />';
const [candidate] = extractHtmlImageCandidates(html, baseUrl);
expect(candidate).toBe('https://scontent.cdninstagram.com/v/t51/img.jpg?stp=dst-jpg_s640x640&_nc_ohc=token&oh=hash&oe=expiry');
expect(candidate).not.toContain('&amp;');
});
it('skips inline base64 placeholder images from HTML', () => {
const html = '<img width="80px" src="data:image/png;base64,iVBORw0KGgo=" /><meta property="og:image" content="https://cdn.example.com/post.jpg" />';
const candidates = extractHtmlImageCandidates(html, baseUrl);
expect(candidates).toEqual(['https://cdn.example.com/post.jpg']);
});
it('prefers the og:image over small profile pictures', () => {
const html = [
'<meta property="og:image" content="https://cdn.example.com/v/post.jpg?stp=dst-jpg_s640x640" />',
'<img src="https://cdn.example.com/v/profile_pic_s150x150.jpg" />',
].join('');
const candidates = extractHtmlImageCandidates(html, baseUrl);
expect(candidates[0]).toContain('s640x640');
});
});
describe('fetchOgImageFromUrl', () => {
it('returns login_wall when the page is a login wall without usable images', async () => {
mockFetchHtml('<html><body><form id="loginForm">accounts/login</form></body></html>');
const result = await fetchOgImageFromUrl('https://www.instagram.com/p/abc/');
expect(result).toEqual({ failureReason: 'login_wall' });
});
it('returns no_image when the page has no images and no login markers', async () => {
mockFetchHtml('<html><body><p>Nothing here</p></body></html>');
const result = await fetchOgImageFromUrl('https://example.com/post');
expect(result).toEqual({ failureReason: 'no_image' });
});
it('returns no_image when the response is not ok', async () => {
mockFetchHtml('', false);
const result = await fetchOgImageFromUrl('https://example.com/missing');
expect(result).toEqual({ failureReason: 'no_image' });
});
it('returns no_image when the fetch throws', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('network down')) as unknown as typeof fetch;
const result = await fetchOgImageFromUrl('https://example.com/offline');
expect(result).toEqual({ failureReason: 'no_image' });
});
});
});

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'expo-router';
import { parseShareIntent, ShareIntentModule } from 'expo-share-intent';
import * as ExpoLinking from 'expo-linking';
import { SHARE_INTENT_KEY, storeSharedImageUri } from '../utils/shareHandoff';
import { resolveSharedImageUri, summarizeShareIntent } from '../utils/shareIntent';
import { isSharedImageResolutionFailure, resolveSharedImageUri, summarizeShareIntent } from '../utils/shareIntent';
const SHARE_INTENT_SCHEME = 'greenlens';
const SHARE_INTENT_OPTIONS = {
@@ -16,6 +16,8 @@ export default function ShareIntentCallbackScreen() {
const router = useRouter();
const [isWaiting, setIsWaiting] = React.useState(true);
const [failureDetails, setFailureDetails] = React.useState<string | null>(null);
const [failureTitle, setFailureTitle] = React.useState<string | null>(null);
const [failureBody, setFailureBody] = React.useState<string | null>(null);
const [previewUri, setPreviewUri] = React.useState<string | null>(null);
const pendingKeyRef = React.useRef<string | null>(null);
const getIntentCalledRef = React.useRef(false);
@@ -33,11 +35,13 @@ export default function ShareIntentCallbackScreen() {
}, []);
useEffect(() => {
const showFailure = (message: string) => {
const showFailure = (message: string, title?: string, body?: string) => {
settledRef.current = true;
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
setPreviewUri(null);
setIsWaiting(false);
setFailureTitle(title ?? null);
setFailureBody(body ?? null);
setFailureDetails(message);
};
@@ -45,13 +49,23 @@ export default function ShareIntentCallbackScreen() {
try {
setIsWaiting(true);
setFailureDetails(null);
setFailureTitle(null);
setFailureBody(null);
const shareIntent = parseShareIntent(event.value, SHARE_INTENT_OPTIONS);
if (__DEV__) {
console.debug('[ShareIntentCallback]', summarizeShareIntent(shareIntent));
}
const resolved = await resolveSharedImageUri(shareIntent);
if (!resolved) {
showFailure('Die Quelle hat keinen nutzbaren Bildanhang oder Bild-Link geliefert.');
if (isSharedImageResolutionFailure(resolved)) {
if (resolved.failureReason === 'login_wall') {
showFailure(
'Die geteilte Seite verlangt einen Login und hat das Bild nicht ausgeliefert.',
'Instagram hat den Post nicht freigegeben',
'Mach einen Screenshot vom Post und teile den Screenshot mit GreenLens — das funktioniert immer.',
);
} else {
showFailure('Die Quelle hat keinen nutzbaren Bildanhang oder Bild-Link geliefert.');
}
return;
}
settledRef.current = true;
@@ -118,9 +132,10 @@ export default function ShareIntentCallbackScreen() {
</View>
) : (
<View style={styles.messageBox}>
<Text style={styles.title}>Bild konnte nicht geladen werden.</Text>
<Text style={styles.title}>{failureTitle ?? 'Bild konnte nicht geladen werden.'}</Text>
<Text style={styles.body}>
Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens.
{failureBody
?? 'Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens.'}
</Text>
{failureDetails ? (
<Text style={styles.detail}>{failureDetails}</Text>

View File

@@ -42,6 +42,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
retryLabel: 'Erneut versuchen',
notAPlantTitle: 'Keine Pflanze erkannt',
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
notAPlantSharedMessage: 'Das geteilte Bild zeigt keine erkennbare Pflanze. Möglicherweise wurde nicht der Original-Post geladen. Mach einen Screenshot vom Post und teile den Screenshot — das funktioniert immer.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
healthTitle: 'Health Check',
@@ -74,6 +75,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
retryLabel: 'Reintentar',
notAPlantTitle: 'No es una planta',
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
notAPlantSharedMessage: 'La imagen compartida no muestra una planta reconocible. Es posible que no se haya cargado la publicación original. Haz una captura de pantalla de la publicación y compártela — eso siempre funciona.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
healthTitle: 'Health Check',
@@ -105,6 +107,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
retryLabel: 'Try again',
notAPlantTitle: 'No plant detected',
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
notAPlantSharedMessage: 'The shared image does not show a recognizable plant. The original post may not have loaded. Take a screenshot of the post and share the screenshot — that always works.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
healthTitle: 'Health Check',
@@ -199,6 +202,7 @@ export default function ScannerScreen() {
const lastProcessedShareToken = useRef<string | null>(null);
const sharedAnalysisInFlightToken = useRef<string | null>(null);
const analysisFromShareRef = useRef(false);
const resizeForAnalysisRef = useRef<(uri: string) => Promise<string>>(async (uri) => uri);
const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise<void>>(async () => {});
@@ -417,7 +421,7 @@ export default function ScannerScreen() {
} else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') {
Alert.alert(
billingCopy.notAPlantTitle,
billingCopy.notAPlantMessage,
analysisFromShareRef.current ? billingCopy.notAPlantSharedMessage : billingCopy.notAPlantMessage,
[{ text: billingCopy.dismiss, style: 'cancel' }],
);
} else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') {
@@ -469,6 +473,7 @@ export default function ScannerScreen() {
if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return;
setDemoResultVisible(false);
setSelectedImage(analysisUri);
analysisFromShareRef.current = true;
await analyzeImageRef.current(analysisUri, nextSharedImageUri);
} finally {
if (sharedAnalysisInFlightToken.current === shareToken) {
@@ -490,6 +495,7 @@ export default function ScannerScreen() {
const analysisUri = await resizeForAnalysis(photo.uri);
setDemoResultVisible(false);
setSelectedImage(analysisUri);
analysisFromShareRef.current = false;
analyzeImage(analysisUri, photo.uri);
}
};
@@ -507,6 +513,7 @@ export default function ScannerScreen() {
const analysisUri = await resizeForAnalysis(asset.uri);
setDemoResultVisible(false);
setSelectedImage(asset.uri);
analysisFromShareRef.current = false;
analyzeImage(analysisUri, asset.uri);
}
};

View File

@@ -426,8 +426,8 @@ h3 {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #162118;
color: #fff;
background: #fff;
color: #162118;
padding: 1rem 2.2rem;
border-radius: var(--r-pill);
font-size: 0.9rem;
@@ -437,7 +437,7 @@ h3 {
}
.btn-primary:hover {
background: #253228;
background: #f0f0f0;
transform: translateY(-3px);
box-shadow: 0 12px 36px rgba(22, 33, 24, 0.4);
}

View File

@@ -0,0 +1,16 @@
keyword,location,device,position,previous_position,delta,ranking_url,search_volume,serp_features,status
pflanzen gießen erinnerung,US,desktop,25.01,,,https://greenlenspro.com/pflanzen-pflege-app,,,new
blumen scanner,US,desktop,8.94,,,https://greenlenspro.com/blumen-scanner,,,new
pflanzensuche,US,desktop,62.57,,,https://greenlenspro.com/pflanzen-bestimmen,,,new
pflanzen bestimmen,US,desktop,66.76,,,https://greenlenspro.com/pflanzen-bestimmen,,,new
flower scanner,US,desktop,10.23,,,https://greenlenspro.com/flower-scanner,,,new
pflanzen diagnose,US,desktop,47.94,,,https://greenlenspro.com/pflanzen-bestimmen,,,new
pflanzen diagnose,US,desktop,56.06,,,https://greenlenspro.com/pflanzen-erkennen-app,,,new
blumenfinder online,US,desktop,58.0,,,https://greenlenspro.com/pflanzen-bestimmen,,,new
blumenfinder online,US,desktop,62.76,,,https://greenlenspro.com/blumen-scanner,,,new
plant identifier by picture,US,desktop,49.84,,,https://greenlenspro.com/identify-plant-photo,,,new
scanning flowers,US,desktop,50.88,,,https://greenlenspro.com/flower-scanner,,,new
blumenfinder online,US,desktop,80.0,,,https://greenlenspro.com/pflanzen-erkennen-app,,,new
pflanzen erkennen,US,desktop,58.82,,,https://greenlenspro.com/pflanzen-bestimmen,,,new
app erinnerung pflanzen gießen,US,desktop,18.2,,,https://greenlenspro.com/pflanzen-pflege-app,,,new
zimmerpflanzen bestimmen,US,desktop,41.95,,,https://greenlenspro.com/zimmerpflanzen-bestimmen,,,new
1 keyword location device position previous_position delta ranking_url search_volume serp_features status
2 pflanzen gießen erinnerung US desktop 25.01 https://greenlenspro.com/pflanzen-pflege-app new
3 blumen scanner US desktop 8.94 https://greenlenspro.com/blumen-scanner new
4 pflanzensuche US desktop 62.57 https://greenlenspro.com/pflanzen-bestimmen new
5 pflanzen bestimmen US desktop 66.76 https://greenlenspro.com/pflanzen-bestimmen new
6 flower scanner US desktop 10.23 https://greenlenspro.com/flower-scanner new
7 pflanzen diagnose US desktop 47.94 https://greenlenspro.com/pflanzen-bestimmen new
8 pflanzen diagnose US desktop 56.06 https://greenlenspro.com/pflanzen-erkennen-app new
9 blumenfinder online US desktop 58.0 https://greenlenspro.com/pflanzen-bestimmen new
10 blumenfinder online US desktop 62.76 https://greenlenspro.com/blumen-scanner new
11 plant identifier by picture US desktop 49.84 https://greenlenspro.com/identify-plant-photo new
12 scanning flowers US desktop 50.88 https://greenlenspro.com/flower-scanner new
13 blumenfinder online US desktop 80.0 https://greenlenspro.com/pflanzen-erkennen-app new
14 pflanzen erkennen US desktop 58.82 https://greenlenspro.com/pflanzen-bestimmen new
15 app erinnerung pflanzen gießen US desktop 18.2 https://greenlenspro.com/pflanzen-pflege-app new
16 zimmerpflanzen bestimmen US desktop 41.95 https://greenlenspro.com/zimmerpflanzen-bestimmen new

View File

@@ -0,0 +1,57 @@
# GreenLens Pro SEO Improver Report 2026-07-15
**Zeitraum:** 2026-06-16 bis 2026-07-13
**Modus:** report-only; keine Live-Dateien geändert
## Executive Summary
- Search Console lieferte 922 Query-/Seiten-Zeilen.
- 15 priorisierte Chancen wurden identifiziert.
- Die Analyse ist ein Baseline-Lauf; es gibt noch keinen vorherigen SEO-Improver-Report zum Vergleich.
## Priorisierte Chancen
| Typ | Suchanfrage | Position | Impressions | CTR | Zielseite |
|---|---|---:|---:|---:|---|
| high-impressions-low-ctr | pflanzen gießen erinnerung | 25.0 | 72 | 0.00% | https://greenlenspro.com/pflanzen-pflege-app |
| striking-distance | blumen scanner | 8.9 | 66 | 0.00% | https://greenlenspro.com/blumen-scanner |
| high-impressions-low-ctr | pflanzensuche | 62.6 | 35 | 0.00% | https://greenlenspro.com/pflanzen-bestimmen |
| high-impressions-low-ctr | pflanzen bestimmen | 66.8 | 33 | 0.00% | https://greenlenspro.com/pflanzen-bestimmen |
| striking-distance | flower scanner | 10.2 | 31 | 0.00% | https://greenlenspro.com/flower-scanner |
| high-impressions-low-ctr | pflanzen diagnose | 47.9 | 31 | 0.00% | https://greenlenspro.com/pflanzen-bestimmen |
| high-impressions-low-ctr | pflanzen diagnose | 56.1 | 31 | 0.00% | https://greenlenspro.com/pflanzen-erkennen-app |
| high-impressions-low-ctr | blumenfinder online | 58.0 | 29 | 0.00% | https://greenlenspro.com/pflanzen-bestimmen |
| high-impressions-low-ctr | blumenfinder online | 62.8 | 29 | 0.00% | https://greenlenspro.com/blumen-scanner |
| high-impressions-low-ctr | plant identifier by picture | 49.8 | 25 | 0.00% | https://greenlenspro.com/identify-plant-photo |
| high-impressions-low-ctr | scanning flowers | 50.9 | 24 | 0.00% | https://greenlenspro.com/flower-scanner |
| high-impressions-low-ctr | blumenfinder online | 80.0 | 24 | 0.00% | https://greenlenspro.com/pflanzen-erkennen-app |
| high-impressions-low-ctr | pflanzen erkennen | 58.8 | 22 | 0.00% | https://greenlenspro.com/pflanzen-bestimmen |
| striking-distance | app erinnerung pflanzen gießen | 18.2 | 20 | 0.00% | https://greenlenspro.com/pflanzen-pflege-app |
| high-impressions-low-ctr | zimmerpflanzen bestimmen | 42.0 | 20 | 0.00% | https://greenlenspro.com/zimmerpflanzen-bestimmen |
## Empfohlene erste Maßnahmen
1. Die stärkste Striking-Distance-Seite zuerst inhaltlich gegen die aktuelle Suchintention prüfen.
2. Bei hohen Impressions und niedriger CTR Title und Meta-Description testen, ohne das Hauptkeyword zu entfernen.
3. Interne Links aus thematisch passenden GreenLens Pro-Seiten auf die priorisierten Zielseiten ergänzen.
4. Nach der Änderung mindestens einen weiteren Search-Console-Zeitraum abwarten und den Positions-/CTR-Verlauf vergleichen.
## DataForSEO-Wettbewerbsabgleich
| Keyword | Rang | Titel | URL |
|---|---:|---|---|
| pflanzen gießen erinnerung | 2 | Planta - dein Pflanzen-Experte Apps bei Google Play | https://play.google.com/store/apps/details?id=com.stromming.planta&hl=de |
| pflanzen gießen erinnerung | 4 | Smarte Apps für lange Pflanzenleben | https://www.plantsandflowersfoundationholland.org/de/smarte-apps-far-lange-pflanzenleben/ |
| pflanzen gießen erinnerung | 5 | Apps für Pflanzen-Pflege? : r/zimmerpflanzen | https://www.reddit.com/r/zimmerpflanzen/comments/19ctt80/apps_f%C3%BCr_pflanzenpflege/ |
| pflanzen gießen erinnerung | 6 | Bewässerung Erinnerung - App Store - Apple | https://apps.apple.com/de/app/bew%C3%A4sserung-erinnerung/id1592638714 |
| pflanzen gießen erinnerung | 7 | Pflanzen gießen Erinnerung - HomeTuning | https://hometuning.io/smart-home-ideen/pflanzen-giessen-erinnerung/ |
| pflanzen gießen erinnerung | 8 | Waterbot: Pflanzen gießen-App im Amazon Appstore | https://www.amazon.de/Nikola-Kosev-Waterbot-Pflanzen-gie%C3%9Fen/dp/B00C0CRO8C |
| pflanzen gießen erinnerung | 9 | Pflanzen richtig gießen App Store - Apple | https://apps.apple.com/at/mac/story/id1604560661 |
| pflanzen gießen erinnerung | 10 | Entdecke 36 Blumen gießen und bewässerung garten Ideen | https://de.pinterest.com/pflanznest/blumen-gie%C3%9Fen/ |
| pflanzen gießen erinnerung | 11 | So überleben Pflanzen den Urlaub ohne Gießen | https://www.ndr.de/ratgeber/garten/So-ueberleben-Pflanzen-Urlaub-ohne-Giessen,urlaub960.html |
## Blocker und Hinweise
- Dieser Lauf hat keine Website-Dateien, GitHub-Branches oder Live-Konfigurationen verändert.
- Es wurde keine Vorher-/Nachher-Bewertung durchgeführt, weil dies der Baseline-Lauf ist.
- Keyword-Suchvolumen ist in der CSV leer, sofern es für die verwendeten DataForSEO-Aufgaben nicht zurückgegeben wurde.

View File

@@ -46,6 +46,7 @@ const {
const {
chargeKey,
consumeCreditsWithIdempotency,
refundCreditsWithIdempotency,
endpointKey,
ensureBillingSchema,
getAccountSnapshot,
@@ -757,9 +758,11 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
app.post('/v1/scan', async (request, response) => {
let userId = 'unknown';
let idempotencyKey = null;
let creditsCharged = 0;
try {
userId = ensureRequestAuth(request);
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri');
const language = normalizeLanguage(request.body?.language);
const endpointId = endpointKey('scan', userId, idempotencyKey);
@@ -770,7 +773,6 @@ app.post('/v1/scan', async (request, response) => {
return;
}
let creditsCharged = 0;
const modelPath = [];
let modelUsed = null;
let modelFallbackCount = 0;
@@ -936,6 +938,19 @@ app.post('/v1/scan', async (request, response) => {
response.status(200).json(payload);
} catch (error) {
console.error(`Scan error for user ${userId}:`, error);
if (error?.code === 'NOT_A_PLANT' && creditsCharged > 0 && idempotencyKey) {
try {
const refunded = await refundCreditsWithIdempotency(
db,
userId,
chargeKey('scan-refund', userId, idempotencyKey),
creditsCharged,
);
console.log(`Refunded ${refunded} credit(s) for user ${userId} (NOT_A_PLANT)`);
} catch (refundError) {
console.error(`Failed to refund credits for user ${userId} (NOT_A_PLANT):`, refundError);
}
}
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}

View File

@@ -623,6 +623,37 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
});
};
const refundCredits = (account, amount) => {
if (amount <= 0) return 0;
// Spiegelbildlich zu consumeCredits: zuerst das Monatskontingent entlasten,
// ein etwaiger Rest wandert auf das Topup-Guthaben zurück.
let remaining = amount;
const monthlyRefund = Math.min(account.usedThisCycle, remaining);
account.usedThisCycle -= monthlyRefund;
remaining -= monthlyRefund;
if (remaining > 0) {
account.topupBalance += remaining;
}
return amount;
};
const refundCreditsWithIdempotency = async (db, userId, key, amount) => {
return runInTransaction(db, async (tx) => {
const existing = await readIdempotentValue(tx, key);
if (existing && typeof existing.refunded === 'number') return existing.refunded;
const account = await getOrCreateAccount(tx, userId);
const refunded = refundCredits(account, amount);
account.updatedAt = nowIso();
await upsertAccount(tx, account);
await writeIdempotentValue(tx, key, { refunded });
return refunded;
});
};
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
@@ -820,6 +851,7 @@ module.exports = {
chargeKey,
claimNotificationOnce,
consumeCreditsWithIdempotency,
refundCreditsWithIdempotency,
endpointKey,
ensureBillingSchema,
getAccountSnapshot,

View File

@@ -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 (&amp;) — undecodiert bricht die
// CDN-Signatur (z. B. Instagrams oh=/oe=-Parameter) und der Download liefert 403.
const decodeHtmlEntities = (value: string): string => value
.replace(/&amp;/gi, '&')
.replace(/&#0*38;/g, '&')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/g, "'")
.replace(/&apos;/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) => ({