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('
');
+ 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 ({