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">

View File

@@ -1,5 +1,6 @@
import { Cloud, Sun, CloudRain, CloudSnow, Wind } from 'lucide-react';
import { Wind } from 'lucide-react';
import { useEffect, useState } from 'react';
import { fetchContextWithGeolocation } from '@/lib/geoDevice';
const WeatherWidget = () => {
const [data, setData] = useState({
@@ -14,103 +15,155 @@ const WeatherWidget = () => {
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const getApproxLocation = async () => {
const res = await fetch('https://ipwhois.app/json/');
const data = await res.json();
return {
latitude: data.latitude,
longitude: data.longitude,
city: data.city,
};
};
const getLocation = async (
callback: (location: {
latitude: number;
longitude: number;
city: string;
}) => void,
const fetchWeather = async (
lat: number,
lng: number,
city: string,
) => {
if (navigator.geolocation) {
const result = await navigator.permissions.query({
name: 'geolocation',
});
const res = await fetch('/api/weather', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat,
lng,
city: city || undefined,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
if (result.state === 'granted') {
navigator.geolocation.getCurrentPosition(async (position) => {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
);
const weatherData = await res.json();
const data = await res.json();
callback({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
city: data.locality,
});
});
} else if (result.state === 'prompt') {
callback(await getApproxLocation());
navigator.geolocation.getCurrentPosition((position) => {});
} else if (result.state === 'denied') {
callback(await getApproxLocation());
}
} else {
callback(await getApproxLocation());
if (res.status !== 200) {
throw new Error(weatherData.message ?? 'Weather fetch failed');
}
setData({
temperature: weatherData.temperature,
condition: weatherData.condition,
location: weatherData.city ?? city ?? 'Unknown',
humidity: weatherData.humidity,
windSpeed: weatherData.windSpeed,
icon: weatherData.icon,
temperatureUnit: weatherData.temperatureUnit,
windSpeedUnit: weatherData.windSpeedUnit,
});
};
const updateWeather = async () => {
getLocation(async (location) => {
const res = await fetch(`/api/weather`, {
method: 'POST',
body: JSON.stringify({
lat: location.latitude,
lng: location.longitude,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
setError(false);
setLoading(true);
const data = await res.json();
if (res.status !== 200) {
console.error('Error fetching weather data');
setLoading(false);
return;
try {
let context: Awaited<ReturnType<typeof fetchContextWithGeolocation>> | null =
null;
try {
context = await fetchContextWithGeolocation();
} catch {
// geo-device не запущен (503) — пропускаем
}
setData({
temperature: data.temperature,
condition: data.condition,
location: location.city,
humidity: data.humidity,
windSpeed: data.windSpeed,
icon: data.icon,
temperatureUnit: data.temperatureUnit,
windSpeedUnit: data.windSpeedUnit,
});
if (context?.geo?.latitude != null && context.geo.longitude != null) {
const city =
context.geo.city ||
(await reverseGeocode(context.geo.latitude, context.geo.longitude));
await fetchWeather(
context.geo.latitude,
context.geo.longitude,
city,
);
} else {
await tryIpFallback();
}
} catch {
await tryIpFallback();
} finally {
setLoading(false);
});
}
};
const reverseGeocode = async (
lat: number,
lng: number,
): Promise<string> => {
try {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
);
const d = await res.json();
return d?.locality ?? '';
} catch {
return '';
}
};
const tryIpFallback = async () => {
const providers: Array<{
url: string;
getCoords: (d: Record<string, unknown>) => { lat: number; lng: number; city: string } | null;
}> = [
{
url: 'https://get.geojs.io/v1/ip/geo.json',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? d.region ?? '').trim() || 'Unknown',
};
}
return null;
},
},
{
url: 'https://ipwhois.app/json/',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? '').trim() || 'Unknown',
};
}
return null;
},
},
];
for (const p of providers) {
try {
const res = await fetch(p.url);
const d = (await res.json()) as Record<string, unknown>;
const coords = p.getCoords(d);
if (coords) {
await fetchWeather(coords.lat, coords.lng, coords.city || 'Unknown');
return;
}
} catch {
// следующий провайдер
}
}
setError(true);
};
useEffect(() => {
updateWeather();
const intervalId = setInterval(updateWeather, 30 * 1000);
const intervalId = setInterval(updateWeather, 30 * 60 * 1000);
return () => clearInterval(intervalId);
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
{loading ? (
{error ? (
<div className="flex items-center justify-center w-full h-full text-xs text-black/50 dark:text-white/50">
Weather unavailable
</div>
) : loading ? (
<>
<div className="flex flex-col items-center justify-center w-16 min-w-16 max-w-16 h-full animate-pulse">
<div className="h-10 w-10 rounded-full bg-light-200 dark:bg-dark-200 mb-2" />

View File

@@ -0,0 +1,131 @@
/**
* Клиент для Geo Device Service.
* Собирает данные с клиента и отправляет на сервис для полного контекста.
*/
const GEO_CONTEXT_API = '/api/geo-context';
export interface GeoDeviceContext {
geo: {
latitude: number;
longitude: number;
country: string;
countryCode: string;
region: string;
city: string;
timezone: string;
source: string;
} | null;
device: {
type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown';
vendor?: string;
model?: string;
};
browser: { name: string; version: string; full: string };
os: { name: string; version: string; full: string };
client: Record<string, unknown>;
ip?: string;
userAgent: string;
acceptLanguage?: string;
requestedAt: string;
}
const collectClientData = (): {
geo?: { latitude: number; longitude: number; city?: string; country?: string; timezone?: string };
client?: {
screenWidth?: number;
screenHeight?: number;
viewportWidth?: number;
viewportHeight?: number;
devicePixelRatio?: number;
timezone?: string;
language?: string;
languages?: string[];
platform?: string;
hardwareConcurrency?: number;
deviceMemory?: number;
cookieEnabled?: boolean;
doNotTrack?: string | null;
};
} => {
if (typeof window === 'undefined') return {};
const nav = navigator;
const screen = window.screen;
return {
client: {
screenWidth: screen?.width,
screenHeight: screen?.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: nav.language,
languages: nav.languages ? [...nav.languages] : undefined,
platform: nav.platform,
hardwareConcurrency: (nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency,
deviceMemory: (nav as Navigator & { deviceMemory?: number }).deviceMemory,
cookieEnabled: nav.cookieEnabled,
doNotTrack: nav.doNotTrack ?? null,
},
};
};
/**
* Получить контекст (только серверные данные, вызывается с API route).
*/
export const fetchContext = async (): Promise<GeoDeviceContext> => {
const res = await fetch(GEO_CONTEXT_API);
if (!res.ok) throw new Error('Failed to fetch context');
return res.json();
};
/**
* Получить полный контекст с данными клиента (вызывать из браузера).
*/
export const fetchContextWithClient = async (): Promise<GeoDeviceContext> => {
const clientData = collectClientData();
const res = await fetch(GEO_CONTEXT_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!res.ok) throw new Error('Failed to fetch context');
return res.json();
};
/**
* С геолокацией из браузера (если пользователь разрешил).
*/
export const fetchContextWithGeolocation = async (): Promise<GeoDeviceContext> => {
const clientData = collectClientData();
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
fetchContextWithClient().then(resolve).catch(reject);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const geo = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
fetch(GEO_CONTEXT_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...clientData, geo }),
})
.then((r) => r.json())
.then(resolve)
.catch(reject);
},
() => fetchContextWithClient().then(resolve).catch(reject),
);
});
};