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