feat: geo-device-service, Weather по геопозиции, Discover вкладка GooSeek

Geo Device Service:
- Новый сервис определения геопозиции, устройства, браузера
- geoip-lite, ua-parser-js, CORS
- GET/POST /api/context

Frontend:
- /api/geo-context — прокси к geo-device, fallback при недоступности
- geoDevice.ts — fetchContextWithClient, fetchContextWithGeolocation
- Weather: геопозиция через geo-device + GeoJS + ipwhois fallback
- Weather API: поддержка city (геокодинг Open-Meteo)
- Discover: вкладка GooSeek по умолчанию

Документация:
- MICROSERVICES.md — секция 3.9 Geo Device Service

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-20 17:20:14 +03:00
parent 783569b8e7
commit 8ba3f5495a
18 changed files with 958 additions and 96 deletions

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
const GEO_DEVICE_URL =
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
const fallbackContext = (userAgent: string) => ({
geo: null,
device: { type: 'unknown' as const },
browser: { name: 'Unknown', version: '', full: '' },
os: { name: 'Unknown', version: '', full: '' },
client: {},
userAgent,
requestedAt: new Date().toISOString(),
});
export async function GET(req: NextRequest) {
try {
const forwarded = req.headers.get('x-forwarded-for');
const realIP = req.headers.get('x-real-ip');
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
headers: {
'x-forwarded-for': forwarded ?? '',
'x-real-ip': realIP ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const forwarded = req.headers.get('x-forwarded-for');
const realIP = req.headers.get('x-real-ip');
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-forwarded-for': forwarded ?? '',
'x-real-ip': realIP ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
}

View File

@@ -1,22 +1,50 @@
const geocodeCity = async (city: string): Promise<{ lat: number; lng: number; name: string } | null> => {
const res = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
);
const data = await res.json();
const result = data.results?.[0];
if (!result?.latitude || !result?.longitude) return null;
return {
lat: result.latitude,
lng: result.longitude,
name: result.name ?? city,
};
};
export const POST = async (req: Request) => {
try {
const body: {
lat: number;
lng: number;
lat?: number;
lng?: number;
city?: string;
measureUnit: 'Imperial' | 'Metric';
} = await req.json();
if (!body.lat || !body.lng) {
let lat: number;
let lng: number;
let cityName: string | undefined = body.city;
if (body.lat != null && body.lng != null) {
lat = body.lat;
lng = body.lng;
} else if (body.city?.trim()) {
const geo = await geocodeCity(body.city.trim());
if (!geo) {
return Response.json({ message: 'City not found.' }, { status: 404 });
}
lat = geo.lat;
lng = geo.lng;
cityName = geo.name;
} else {
return Response.json(
{
message: 'Invalid request.',
},
{ message: 'Invalid request. Provide lat/lng or city.' },
{ status: 400 },
);
}
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
);
@@ -159,7 +187,7 @@ export const POST = async (req: Request) => {
break;
}
return Response.json(weather);
return Response.json({ ...weather, city: cityName });
} catch (err) {
console.error('An error occurred while getting home widgets', err);
return Response.json(

View File

@@ -15,6 +15,10 @@ export interface Discover {
}
const topics: { key: string; display: string }[] = [
{
display: 'GooSeek',
key: 'gooseek',
},
{
display: 'Tech & Science',
key: 'tech',
@@ -39,7 +43,7 @@ const topics: { key: string; display: string }[] = [
const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const [setupRequired, setSetupRequired] = useState(false);
@@ -78,6 +82,12 @@ const Page = () => {
};
useEffect(() => {
if (activeTopic === 'gooseek') {
setLoading(false);
setDiscover(null);
setSetupRequired(false);
return;
}
fetchArticles(activeTopic);
}, [activeTopic]);
@@ -133,6 +143,8 @@ const Page = () => {
/>
</svg>
</div>
) : activeTopic === 'gooseek' ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4" />
) : setupRequired ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">
<div className="rounded-full p-4 bg-cyan-300/10 dark:bg-cyan-300/5">