diff --git a/__tests__/utils/shareIntent.test.ts b/__tests__/utils/shareIntent.test.ts new file mode 100644 index 0000000..49a753e --- /dev/null +++ b/__tests__/utils/shareIntent.test.ts @@ -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('Log in')).toBe(true); + expect(detectLoginWall('{"page":"LoginAndSignupPage"}')).toBe(true); + expect(detectLoginWall('
')).toBe(true); + expect(detectLoginWall('')).toBe(true); + }); + + it('does not flag regular post pages', () => { + expect(detectLoginWall('')).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 = ''; + 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('&'); + }); + + it('skips inline base64 placeholder images from HTML', () => { + const html = ''; + 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 = [ + '', + '', + ].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('accounts/login
'); + 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('

Nothing here

'); + 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' }); + }); + }); +}); diff --git a/app/dataUrl=greenlensShareKey.tsx b/app/dataUrl=greenlensShareKey.tsx index 9191602..b444a16 100644 --- a/app/dataUrl=greenlensShareKey.tsx +++ b/app/dataUrl=greenlensShareKey.tsx @@ -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(null); + const [failureTitle, setFailureTitle] = React.useState(null); + const [failureBody, setFailureBody] = React.useState(null); const [previewUri, setPreviewUri] = React.useState(null); const pendingKeyRef = React.useRef(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() { ) : ( - Bild konnte nicht geladen werden. + {failureTitle ?? 'Bild konnte nicht geladen werden.'} - 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.'} {failureDetails ? ( {failureDetails} diff --git a/app/scanner.tsx b/app/scanner.tsx index 7aea806..16cea69 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -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(null); const sharedAnalysisInFlightToken = useRef(null); + const analysisFromShareRef = useRef(false); const resizeForAnalysisRef = useRef<(uri: string) => Promise>(async (uri) => uri); const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise>(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); } }; diff --git a/greenlns-landing/app/globals.css b/greenlns-landing/app/globals.css index 6a1f1cf..8d15cfb 100644 --- a/greenlns-landing/app/globals.css +++ b/greenlns-landing/app/globals.css @@ -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); } diff --git a/reports/seo-improver/2026-07-15/rankings.csv b/reports/seo-improver/2026-07-15/rankings.csv new file mode 100644 index 0000000..b69bcc8 --- /dev/null +++ b/reports/seo-improver/2026-07-15/rankings.csv @@ -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 diff --git a/reports/seo-improver/2026-07-15/report.md b/reports/seo-improver/2026-07-15/report.md new file mode 100644 index 0000000..8342949 --- /dev/null +++ b/reports/seo-improver/2026-07-15/report.md @@ -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. diff --git a/server/index.js b/server/index.js index 86cff5c..fcda0d4 100644 --- a/server/index.js +++ b/server/index.js @@ -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); } diff --git a/server/lib/billing.js b/server/lib/billing.js index 8e46186..81557f3 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -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, diff --git a/utils/shareIntent.ts b/utils/shareIntent.ts index 84a7502..8abcd55 100644 --- a/utils/shareIntent.ts +++ b/utils/shareIntent.ts @@ -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 => 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 => { - 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 = [ /]*?\s+)?property=["']og:image:secure_url["'][^>]*\s+content=["']([^"']+)["']/gi, /]*?\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 { +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 { '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 => { +export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise => { const directFileUri = getDirectSharedImageFileUri(shareIntent.files); if (directFileUri) { return { uri: directFileUri, requiresConfirmation: false }; @@ -273,7 +323,16 @@ export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise-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 ({