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:
78
apps/frontend/src/app/api/geo-context/route.ts
Normal file
78
apps/frontend/src/app/api/geo-context/route.ts
Normal 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') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
131
apps/frontend/src/lib/geoDevice.ts
Normal file
131
apps/frontend/src/lib/geoDevice.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
};
|
||||
5
apps/geo-device-service/.gitignore
vendored
Normal file
5
apps/geo-device-service/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
57
apps/geo-device-service/README.md
Normal file
57
apps/geo-device-service/README.md
Normal file
@@ -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
|
||||
```
|
||||
23
apps/geo-device-service/package.json
Normal file
23
apps/geo-device-service/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
20
apps/geo-device-service/src/index.ts
Normal file
20
apps/geo-device-service/src/index.ts
Normal file
@@ -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)`);
|
||||
});
|
||||
69
apps/geo-device-service/src/lib/context.ts
Normal file
69
apps/geo-device-service/src/lib/context.ts
Normal file
@@ -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, string | string[] | undefined> }): 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<string, string | string[] | undefined>; socket?: { remoteAddress?: string } },
|
||||
clientData?: Partial<{
|
||||
geo: Partial<GeoLocation>;
|
||||
client: Partial<ClientContext>;
|
||||
}>,
|
||||
): 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(),
|
||||
};
|
||||
};
|
||||
35
apps/geo-device-service/src/lib/device.ts
Normal file
35
apps/geo-device-service/src/lib/device.ts
Normal file
@@ -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(' '),
|
||||
},
|
||||
};
|
||||
};
|
||||
22
apps/geo-device-service/src/lib/geo.ts
Normal file
22
apps/geo-device-service/src/lib/geo.ts
Normal file
@@ -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',
|
||||
};
|
||||
};
|
||||
54
apps/geo-device-service/src/routes/context.ts
Normal file
54
apps/geo-device-service/src/routes/context.ts
Normal file
@@ -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<object, object, ClientBody>, 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;
|
||||
56
apps/geo-device-service/src/types.ts
Normal file
56
apps/geo-device-service/src/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
apps/geo-device-service/tsconfig.json
Normal file
13
apps/geo-device-service/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user