From 8ba3f5495a79b767389d6c61f9cd860b33b6473b Mon Sep 17 00:00:00 2001 From: home Date: Fri, 20 Feb 2026 17:20:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20geo-device-service,=20Weather=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B3=D0=B5=D0=BE=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D0=B8,=20Discover=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0?= =?UTF-8?q?=20GooSeek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frontend/src/app/api/geo-context/route.ts | 78 +++++++ apps/frontend/src/app/api/weather/route.ts | 44 +++- apps/frontend/src/app/discover/page.tsx | 14 +- .../frontend/src/components/WeatherWidget.tsx | 207 +++++++++++------- apps/frontend/src/lib/geoDevice.ts | 131 +++++++++++ apps/geo-device-service/.gitignore | 5 + apps/geo-device-service/README.md | 57 +++++ apps/geo-device-service/package.json | 23 ++ apps/geo-device-service/src/index.ts | 20 ++ apps/geo-device-service/src/lib/context.ts | 69 ++++++ apps/geo-device-service/src/lib/device.ts | 35 +++ apps/geo-device-service/src/lib/geo.ts | 22 ++ apps/geo-device-service/src/routes/context.ts | 54 +++++ apps/geo-device-service/src/types.ts | 56 +++++ apps/geo-device-service/tsconfig.json | 13 ++ docs/architecture/MICROSERVICES.md | 24 ++ package-lock.json | 199 ++++++++++++++++- package.json | 3 +- 18 files changed, 958 insertions(+), 96 deletions(-) create mode 100644 apps/frontend/src/app/api/geo-context/route.ts create mode 100644 apps/frontend/src/lib/geoDevice.ts create mode 100644 apps/geo-device-service/.gitignore create mode 100644 apps/geo-device-service/README.md create mode 100644 apps/geo-device-service/package.json create mode 100644 apps/geo-device-service/src/index.ts create mode 100644 apps/geo-device-service/src/lib/context.ts create mode 100644 apps/geo-device-service/src/lib/device.ts create mode 100644 apps/geo-device-service/src/lib/geo.ts create mode 100644 apps/geo-device-service/src/routes/context.ts create mode 100644 apps/geo-device-service/src/types.ts create mode 100644 apps/geo-device-service/tsconfig.json diff --git a/apps/frontend/src/app/api/geo-context/route.ts b/apps/frontend/src/app/api/geo-context/route.ts new file mode 100644 index 0000000..995d17d --- /dev/null +++ b/apps/frontend/src/app/api/geo-context/route.ts @@ -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') ?? ''), + ); + } +} diff --git a/apps/frontend/src/app/api/weather/route.ts b/apps/frontend/src/app/api/weather/route.ts index afaf8a6..17ca4d4 100644 --- a/apps/frontend/src/app/api/weather/route.ts +++ b/apps/frontend/src/app/api/weather/route.ts @@ -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}¤t=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}¤t=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( diff --git a/apps/frontend/src/app/discover/page.tsx b/apps/frontend/src/app/discover/page.tsx index a56957a..c62bedd 100644 --- a/apps/frontend/src/app/discover/page.tsx +++ b/apps/frontend/src/app/discover/page.tsx @@ -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(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [activeTopic, setActiveTopic] = useState(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 = () => { /> + ) : activeTopic === 'gooseek' ? ( +
) : setupRequired ? (
diff --git a/apps/frontend/src/components/WeatherWidget.tsx b/apps/frontend/src/components/WeatherWidget.tsx index a7ebcff..c5e7f17 100644 --- a/apps/frontend/src/components/WeatherWidget.tsx +++ b/apps/frontend/src/components/WeatherWidget.tsx @@ -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> | 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 => { + 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) => { 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; + 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 (
- {loading ? ( + {error ? ( +
+ Weather unavailable +
+ ) : loading ? ( <>
diff --git a/apps/frontend/src/lib/geoDevice.ts b/apps/frontend/src/lib/geoDevice.ts new file mode 100644 index 0000000..0c1b4d4 --- /dev/null +++ b/apps/frontend/src/lib/geoDevice.ts @@ -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; + 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 => { + 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 => { + 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 => { + 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), + ); + }); +}; diff --git a/apps/geo-device-service/.gitignore b/apps/geo-device-service/.gitignore new file mode 100644 index 0000000..4274b51 --- /dev/null +++ b/apps/geo-device-service/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/apps/geo-device-service/README.md b/apps/geo-device-service/README.md new file mode 100644 index 0000000..48f1938 --- /dev/null +++ b/apps/geo-device-service/README.md @@ -0,0 +1,57 @@ +# Geo Device Service + +Сервис определения геопозиции, устройства и браузера пользователя. + +## Возможности + +- **Геолокация по IP** — offline lookup через geoip-lite (MaxMind GeoLite) +- **Device** — тип (desktop/mobile/tablet), vendor, model +- **Browser** — имя, версия +- **OS** — операционная система и версия +- **Client context** — при POST с body: screen size, timezone, language, hardwareConcurrency, deviceMemory, doNotTrack + +## API + +### GET /api/context + +Возвращает контекст на основе IP и заголовков запроса. + +### POST /api/context + +Принимает дополнительные данные от клиента (geo из Geolocation API, размер экрана, timezone и т.д.) и объединяет с серверными данными. + +**Body:** +```json +{ + "geo": { + "latitude": 55.7558, + "longitude": 37.6173, + "city": "Moscow", + "country": "Russia", + "timezone": "Europe/Moscow" + }, + "client": { + "screenWidth": 1920, + "screenHeight": 1080, + "viewportWidth": 1920, + "viewportHeight": 969, + "devicePixelRatio": 2, + "timezone": "Europe/Moscow", + "language": "en", + "languages": ["en", "ru"], + "platform": "MacIntel", + "hardwareConcurrency": 8, + "deviceMemory": 8, + "cookieEnabled": true, + "doNotTrack": null + } +} +``` + +## Запуск + +```bash +npm run dev +# или +PORT=4002 npm start +``` diff --git a/apps/geo-device-service/package.json b/apps/geo-device-service/package.json new file mode 100644 index 0000000..388359c --- /dev/null +++ b/apps/geo-device-service/package.json @@ -0,0 +1,23 @@ +{ + "name": "geo-device-service", + "version": "1.0.0", + "type": "module", + "description": "Сервис определения геопозиции, устройства и браузера пользователя", + "main": "src/index.ts", + "scripts": { + "dev": "npx tsx watch src/index.ts", + "start": "npx tsx src/index.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "geoip-lite": "^1.4.9", + "ua-parser-js": "^1.0.41" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/ua-parser-js": "^0.7.39", + "tsx": "^4.19.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/geo-device-service/src/index.ts b/apps/geo-device-service/src/index.ts new file mode 100644 index 0000000..798740d --- /dev/null +++ b/apps/geo-device-service/src/index.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import contextRouter from './routes/context.js'; + +const app = express(); + +app.use(cors({ origin: true })); +const PORT = process.env.PORT || 4002; + +app.use(express.json({ limit: '1mb' })); + +app.use('/api', contextRouter); + +app.get('/health', (_, res) => res.json({ status: 'ok', service: 'geo-device' })); + +app.listen(PORT, () => { + console.log(`Geo Device Service: http://localhost:${PORT}`); + console.log(` Context (GET): /api/context`); + console.log(` Context (POST): /api/context (with client data in body)`); +}); diff --git a/apps/geo-device-service/src/lib/context.ts b/apps/geo-device-service/src/lib/context.ts new file mode 100644 index 0000000..6f8b673 --- /dev/null +++ b/apps/geo-device-service/src/lib/context.ts @@ -0,0 +1,69 @@ +import type { GeoDeviceContext, GeoLocation, ClientContext } from '../types.js'; +import { lookupByIP } from './geo.js'; +import { parseUserAgent } from './device.js'; + +const getClientIP = (req: { headers: Record }): string => { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim(); + } + if (Array.isArray(forwarded)) { + return (forwarded[0] ?? '').split(',')[0].trim(); + } + return (req.headers['x-real-ip'] as string) ?? ''; +}; + +export const buildContext = ( + req: { headers: Record; socket?: { remoteAddress?: string } }, + clientData?: Partial<{ + geo: Partial; + client: Partial; + }>, +): GeoDeviceContext => { + const ip = getClientIP(req) || req.socket?.remoteAddress?.replace(/^::ffff:/, '') || ''; + const userAgent = (req.headers['user-agent'] as string) ?? ''; + const acceptLanguage = (req.headers['accept-language'] as string) ?? undefined; + + const { device, browser, os } = parseUserAgent(userAgent); + + let geo: GeoLocation | null = clientData?.geo + ? { + latitude: clientData.geo.latitude ?? 0, + longitude: clientData.geo.longitude ?? 0, + country: clientData.geo.country ?? '', + countryCode: clientData.geo.countryCode ?? '', + region: clientData.geo.region ?? '', + city: clientData.geo.city ?? '', + timezone: clientData.geo.timezone ?? '', + source: 'client', + } + : lookupByIP(ip); + + const client: ClientContext = { + screenWidth: clientData?.client?.screenWidth, + screenHeight: clientData?.client?.screenHeight, + viewportWidth: clientData?.client?.viewportWidth, + viewportHeight: clientData?.client?.viewportHeight, + devicePixelRatio: clientData?.client?.devicePixelRatio, + timezone: clientData?.client?.timezone, + language: clientData?.client?.language, + languages: clientData?.client?.languages, + platform: clientData?.client?.platform, + hardwareConcurrency: clientData?.client?.hardwareConcurrency, + deviceMemory: clientData?.client?.deviceMemory, + cookieEnabled: clientData?.client?.cookieEnabled, + doNotTrack: clientData?.client?.doNotTrack, + }; + + return { + geo, + device, + browser, + os, + client, + ip: ip || undefined, + userAgent, + acceptLanguage, + requestedAt: new Date().toISOString(), + }; +}; diff --git a/apps/geo-device-service/src/lib/device.ts b/apps/geo-device-service/src/lib/device.ts new file mode 100644 index 0000000..fca0cc7 --- /dev/null +++ b/apps/geo-device-service/src/lib/device.ts @@ -0,0 +1,35 @@ +import UAParser from 'ua-parser-js'; +import type { DeviceInfo, BrowserInfo, OSInfo } from '../types.js'; + +export const parseUserAgent = (ua: string): { + device: DeviceInfo; + browser: BrowserInfo; + os: OSInfo; +} => { + const parser = new UAParser(ua); + const result = parser.getResult(); + + const deviceType = result.device.type as DeviceInfo['type'] | undefined; + const type: DeviceInfo['type'] = + deviceType && ['mobile', 'tablet', 'wearable', 'smarttv', 'desktop'].includes(deviceType) + ? deviceType + : 'desktop'; + + return { + device: { + type, + vendor: result.device.vendor || undefined, + model: result.device.model || undefined, + }, + browser: { + name: result.browser.name ?? 'Unknown', + version: result.browser.version ?? '', + full: [result.browser.name, result.browser.version].filter(Boolean).join(' '), + }, + os: { + name: result.os.name ?? 'Unknown', + version: result.os.version ?? '', + full: [result.os.name, result.os.version].filter(Boolean).join(' '), + }, + }; +}; diff --git a/apps/geo-device-service/src/lib/geo.ts b/apps/geo-device-service/src/lib/geo.ts new file mode 100644 index 0000000..5f341c5 --- /dev/null +++ b/apps/geo-device-service/src/lib/geo.ts @@ -0,0 +1,22 @@ +import geoip from 'geoip-lite'; +import type { GeoLocation } from '../types.js'; + +export const lookupByIP = (ip: string): GeoLocation | null => { + if (!ip || ip === '::1' || ip === '127.0.0.1') { + return null; + } + + const result = geoip.lookup(ip); + if (!result) return null; + + return { + latitude: result.ll?.[0] ?? 0, + longitude: result.ll?.[1] ?? 0, + country: result.country ?? '', + countryCode: result.country ?? '', + region: result.region ?? '', + city: result.city ?? '', + timezone: result.timezone ?? '', + source: 'ip', + }; +}; diff --git a/apps/geo-device-service/src/routes/context.ts b/apps/geo-device-service/src/routes/context.ts new file mode 100644 index 0000000..5429a80 --- /dev/null +++ b/apps/geo-device-service/src/routes/context.ts @@ -0,0 +1,54 @@ +import { Router, type Request, type Response } from 'express'; +import { buildContext } from '../lib/context.js'; + +const router = Router(); + +interface ClientBody { + geo?: { + latitude?: number; + longitude?: number; + country?: string; + countryCode?: string; + region?: string; + city?: 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; + }; +} + +router.get('/context', (req: Request, res: Response) => { + try { + const context = buildContext(req); + res.json(context); + } catch (err) { + console.error('Error building context:', err); + res.status(500).json({ error: 'Failed to build context' }); + } +}); + +router.post('/context', (req: Request, res: Response) => { + try { + const clientData = req.body; + const context = buildContext(req, clientData); + res.json(context); + } catch (err) { + console.error('Error building context:', err); + res.status(500).json({ error: 'Failed to build context' }); + } +}); + +export default router; diff --git a/apps/geo-device-service/src/types.ts b/apps/geo-device-service/src/types.ts new file mode 100644 index 0000000..8fa34eb --- /dev/null +++ b/apps/geo-device-service/src/types.ts @@ -0,0 +1,56 @@ +export interface GeoLocation { + latitude: number; + longitude: number; + country: string; + countryCode: string; + region: string; + city: string; + timezone: string; + source: 'ip' | 'client' | 'unknown'; +} + +export interface DeviceInfo { + type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown'; + vendor?: string; + model?: string; +} + +export interface BrowserInfo { + name: string; + version: string; + full: string; +} + +export interface OSInfo { + name: string; + version: string; + full: string; +} + +export interface ClientContext { + 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; +} + +export interface GeoDeviceContext { + geo: GeoLocation | null; + device: DeviceInfo; + browser: BrowserInfo; + os: OSInfo; + client: ClientContext; + ip?: string; + userAgent: string; + acceptLanguage?: string; + requestedAt: string; +} diff --git a/apps/geo-device-service/tsconfig.json b/apps/geo-device-service/tsconfig.json new file mode 100644 index 0000000..83233c7 --- /dev/null +++ b/apps/geo-device-service/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/docs/architecture/MICROSERVICES.md b/docs/architecture/MICROSERVICES.md index 8de826c..4c2c976 100644 --- a/docs/architecture/MICROSERVICES.md +++ b/docs/architecture/MICROSERVICES.md @@ -233,6 +233,29 @@ --- +### 3.9 Geo Device Service (реализован) + +**Назначение:** Определение геопозиции пользователя, типа устройства, браузера и ОС. + +**Расположение:** `apps/geo-device-service/` + +**API:** +- `GET /api/context` — контекст по IP и заголовкам (User-Agent, Accept-Language) +- `POST /api/context` — контекст с дополнительными данными от клиента (screen, timezone, Geolocation API) + +**Данные:** +- **geo** — latitude, longitude, city, country, timezone (geoip-lite по IP или из body) +- **device** — type (desktop/mobile/tablet), vendor, model +- **browser** — name, version (ua-parser-js) +- **os** — name, version +- **client** — screenWidth, viewportHeight, timezone, language, hardwareConcurrency, deviceMemory, doNotTrack (из POST body) + +**Запуск:** `npm run dev:geo` или `PORT=4002 npm run start -w geo-device-service` + +**Интеграция:** Frontend вызывает `/api/geo-context` (проксирует к сервису), клиент — `fetchContextWithClient()` из `@/lib/geoDevice`. + +--- + ## 4. Матрица зависимостей | Сервис | От кого получает вызовы | Кого вызывает | @@ -262,6 +285,7 @@ gooseek/ │ ├── uploads-service/ # UploadManager, UploadStore │ ├── storage-service/ # DB, config │ ├── llm-proxy/ # Models registry, providers +│ ├── geo-device-service/ # Геопозиция, устройство, браузер │ ├── shared-types/ # Общие типы, DTO │ └── shared-utils/ # formatHistory, splitText, computeSimilarity ├── docker-compose.yaml diff --git a/package-lock.json b/package-lock.json index 7302b34..e74ff03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -352,6 +352,21 @@ "@napi-rs/canvas": "^0.1.87" } }, + "apps/geo-device-service": { + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "geoip-lite": "^1.4.9", + "ua-parser-js": "^1.0.41" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/ua-parser-js": "^0.7.39", + "tsx": "^4.19.0", + "typescript": "^5.9.3" + } + }, "apps/posts-mcs": { "name": "posts-microservice", "version": "1.0.0", @@ -3664,6 +3679,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4433,6 +4455,15 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4841,7 +4872,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5073,7 +5103,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -5245,7 +5274,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -5331,6 +5359,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6806,6 +6851,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -7174,7 +7228,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -7333,6 +7386,72 @@ "node": ">= 0.4" } }, + "node_modules/geo-device-service": { + "resolved": "apps/geo-device-service", + "link": true + }, + "node_modules/geoip-lite": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz", + "integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==", + "license": "Apache-2.0", + "dependencies": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/geoip-lite/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/geoip-lite/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/geoip-lite/node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7619,7 +7738,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7876,7 +7994,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7916,6 +8033,26 @@ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, + "node_modules/ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8504,6 +8641,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -8670,6 +8813,15 @@ "node": ">=0.10" } }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/ldap-authentication": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/ldap-authentication/-/ldap-authentication-3.3.6.tgz", @@ -8781,6 +8933,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9046,7 +9204,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9461,7 +9618,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12069,7 +12225,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -13115,6 +13270,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 3b71212..8467989 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format:write": "prettier . --write", "docker:build": "docker build -f docker/Dockerfile -t gooseek:latest .", "docker:up": "docker compose -f docker/docker-compose.yaml up -d", - "docker:down": "docker compose -f docker/docker-compose.yaml down" + "docker:down": "docker compose -f docker/docker-compose.yaml down", + "dev:geo": "npm run dev -w geo-device-service" }, "devDependencies": { "prettier": "^3.2.5"