[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:ru":3,"public-menus:all":38,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:ru":205,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:ru:1":910},{"statusCode":4,"data":5,"message":37},200,{"tenantId":6,"lang":7,"defaultLang":8,"siteUrl":9,"contactEmail":10,"brandName":11,"logoUrl":12,"siteName":11,"siteDescription":13,"ogImage":10,"robotsIndex":14,"socialLinks":10,"reservedSlugs":10,"seoPolicy":15},"stajic","ru","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":16,"relatedContent":17,"crossDomainLinks":18},{"logoUrl":12},{"enabled":14},[19,22,25,28,31,34],{"url":20,"label":21,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":23,"label":24,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":26,"label":27,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.com","bazify.com",{"url":29,"label":30,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.de","bazify.de",{"url":32,"label":33,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.at","bazify.at",{"url":35,"label":36,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[39,45],{"id":40,"name":41,"location":42,"isActive":14,"isDefault":43,"items":44},1,"main-navigation","header",false,[],{"id":46,"name":47,"location":48,"isActive":14,"isDefault":14,"items":49},4,"main-menu","sidebar",[50,66,79,93,103,118,133],{"id":51,"title":52,"url":60,"target":61,"icon":62,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":64,"portfolioId":10,"children":65},"item-18",{"de":53,"en":54,"es":55,"fr":56,"it":54,"ru":57,"sr":58,"zh":59},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":67,"title":68,"url":75,"target":61,"icon":76,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":77,"portfolioId":10,"children":78},"item-22",{"de":69,"en":69,"es":70,"fr":69,"it":71,"ru":72,"sr":73,"zh":74},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":80,"title":81,"url":89,"target":61,"icon":90,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":91,"portfolioId":10,"children":92},"item-19",{"de":82,"en":83,"es":84,"fr":83,"it":85,"ru":86,"sr":87,"zh":88},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":94,"title":95,"url":99,"target":61,"icon":100,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":101,"portfolioId":10,"children":102},"item-23",{"de":96,"en":96,"es":96,"fr":96,"it":96,"ru":97,"sr":97,"zh":98},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":104,"title":105,"url":114,"target":61,"icon":115,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":116,"portfolioId":10,"children":117},"item-32",{"de":106,"en":107,"es":108,"fr":109,"it":110,"ru":111,"sr":112,"zh":113},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":119,"title":120,"url":129,"target":61,"icon":130,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":131,"portfolioId":10,"children":132},"item-20",{"de":121,"en":122,"es":123,"fr":124,"it":125,"ru":126,"sr":127,"zh":128},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":134,"title":135,"url":144,"target":61,"icon":145,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":147},"item-21",{"de":136,"en":137,"es":138,"fr":139,"it":140,"ru":141,"sr":142,"zh":143},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[148,161,175,181,193],{"id":149,"title":150,"url":144,"target":61,"icon":159,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":160},"item-24",{"de":151,"en":152,"es":153,"fr":154,"it":155,"ru":156,"sr":157,"zh":158},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":162,"title":163,"url":171,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":174},"item-29",{"de":164,"en":165,"es":166,"fr":167,"it":168,"ru":169,"sr":170,"zh":143},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-folder","custom",[],{"id":176,"title":177,"url":179,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":180},"item-28",{"de":178,"en":178,"es":178,"fr":178,"it":178,"ru":178,"sr":178,"zh":178},"Solr Suggester","\u002Fportfolio\u002Fsolr-fuzzy-suggester-und-solr-infix-suggester-abfrage-ueber-ajax-und-filterung",[],{"id":182,"title":183,"url":191,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":192},"item-27",{"de":184,"en":185,"es":186,"fr":187,"it":188,"ru":189,"sr":190,"zh":185},"Firmenwebseite SEO","Company Website SEO","Sitio web corporativo SEO","Site web d’entreprise SEO","Sito web aziendale SEO","Корпоративный сайт SEO","Пословна веб-страница SEO","\u002Fportfolio\u002Fseo-sem-branding-mobile-webseite-muenchen",[],{"id":194,"title":195,"url":203,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":204},"item-31",{"de":196,"en":197,"es":198,"fr":199,"it":200,"ru":201,"sr":202,"zh":197},"Digitalisierungsportal","Digitalization Portal","Portal de digitalización","Portail de numérisation","Portale di digitalizzazione","Портал цифровизации","Портал за дигитализацију","\u002Fportfolio\u002Fdigitalisierungsportal-archiv-museum-bibliothek-ead-lido-mets-mods",[],{"statusCode":4,"data":206,"message":909},{"id":207,"title":208,"slug":209,"content":210,"contentJson":211,"excerpt":510,"featuredImage":511,"featuredImageAlt":512,"featuredImageCaption":10,"featuredImageTitle":10,"featuredImageCopyright":10,"featuredImageAuthor":10,"featuredImageSourceUrl":10,"featuredImageLicense":10,"featuredImageIsAiGenerated":43,"status":513,"publishedAt":514,"createdAt":515,"updatedAt":516,"seoLocalePaths":517,"categories":526,"author":547,"translations":552},"383","Каноническая архитектура, Дизайн URL, Логика резолвера, Спецификация API и масштабируемости","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: каноническая архитектура, дизайн URL, логика резолвера, API и спецификация масштабируемости\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"Этот документ описывает поверхность географического поиска (страна\u002Fгород\u002Fрадиус) для нескольких порталов (multi-tenant), без немедленного рефакторинга базы данных и без жёсткой связи с маршрутизацией CMS (страница\u002Fпост). Он сохраняет стабильность SEO, остаётся дружественным к кэшу и оставляет пространство для бронирований, отзывов и карт, не превращая приложение в монолит.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Ограничения и не-цели\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: одна и та же кодовая база обслуживает несколько порталов. Tenant влияет на область контента, брендинг и иногда на источник данных.\",\"Географический поиск должен поддерживать: страну, город и поиск по радиусу (вокруг точки).\",\"Без немедленного рефакторинга базы данных: мы не можем прямо сейчас перестроить существующие таблицы в идеальную geo-схему.\",\"Независимость от маршрутизации CMS: geo-страницы не являются «постами» или «страницами». Они не должны блокироваться конфликтами slug CMS.\",\"Стабильность SEO: канонические URL не должны меняться при изменении фильтров или сортировки.\",\"Дружественность к кэшу: CDN и серверный кэш должны иметь предсказуемые ключи. Избегать вариаций на пользователя.\",\"Строгое разделение ответственности: discovery, CMS и резолвинг tenant — это отдельные модули с чёткими границами.\",\"Не-цель (пока): идеальное геокодирование. Мы принимаем один геокодер, одну стратегию нормализации и сохраняем нормализованный результат.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Каноническая архитектура\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"Мы реализуем географический discovery как отдельный bounded context с минимальной публичной поверхностью: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Response → SEO и метаданные кэша. Маршруты CMS никогда не вызывают discovery. Discovery никогда не вызывает маршрутизацию CMS. Они разделяют только низкоуровневые утилиты (HTTP, кэш, tenant-контекст).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Ключевые компоненты\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: определяет tenant по заголовку Host (или по явному идентификатору портала во внутренних вызовах).\",\"GeoResolver: парсит и валидирует geo-сегменты URL; формирует нормализованный GeoQuery.\",\"GeoIndex (Read Model): отдельная таблица\u002Fколлекция, которая сопоставляет entityId → lat\u002Flng + tenant scope + минимальные поля для поиска. Это позволяет избежать рефакторинга исходных таблиц БД.\",\"Data Providers: подключаемые источники (например, существующие SQL-таблицы, WordPress API, сервис другого портала), скрытые за интерфейсом.\",\"SEO Router: формирует канонический URL и метаданные (canonical, hreflang при необходимости, robots-флаги).\",\"Слой кэширования: ключи CDN-кэша и серверного кэша с tenant-скоупом и версионированием.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Структура каталогов\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"Дизайн URL (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"Мы сохраняем канонический URL строго иерархическим и человекочитаемым. Фильтры и сортировка остаются в querystring, но НЕ влияют на canonical. Поиск по радиусу использует стабильную страницу «near» с canonical, основанным на округлённой координатной ячейке, а не на сырых lat\u002Flng. Это предотвращает бесконечное количество вариантов URL и взрыв кэша. Это осознанный компромисс.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Страница страны: \u002Fdiscover\u002F{countryCode} (пример: \u002Fdiscover\u002Fde)\",\"Страница города: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (пример: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (радиус): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (пример: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z), где cellId — короткий идентификатор в стиле geohash\",\"Необязательные параметры запроса (не канонические): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant НЕ включён в путь. Tenant определяется хостом (portal-a.tld, portal-b.tld). Внутренние вызовы передают x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Правила каноникализации\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Страницы страны\u002Fгорода канонизируются на собственный чистый путь (без querystring).\",\"Страницы near канонизируются на \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Canonical формируется на основе округлённой ячейки, а не входных координат.\",\"page=1 исключается из canonical и из генерации внутренних ссылок.\",\"Неподдерживаемые комбинации (например, несоответствие страны) возвращают 404, а не редирект. Цепочки редиректов ухудшали crawl budget при тестировании.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Логика резолвера\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Вход резолвера — (tenant, pathname, query). Выход — нормализованный GeoQuery с query plan. Доступа к базе данных внутри резолвера нет. Это разделение оказалось критически важным позже и спасло нас от неприятной ошибки кэширования.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Пошаговый поток запроса\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN принимает запрос. Ключ кэша включает host + путь + ограниченные параметры запроса (q, category, sort, page, pageSize, r).\",\"Сервер приложения создаёт TenantContext из заголовка Host. Доступа к БД на этом этапе нет.\",\"GeoResolver парсит URL и формирует GeoQuery с canonicalPath и server cacheKey.\",\"GeoController строит QueryPlan и решает, какие provider’ы вызывать, исходя из конфигурации tenant и типа scope.\",\"Provider выполняет запрос read-model к GeoIndex (быстро), затем гидратирует результаты из существующего источника (SQL БД, CMS API) по entity ID.\",\"Сборщик ответа добавляет SEO-метаданные: canonical, robots, ссылки пагинации.\",\"Сервер устанавливает заголовки кэша (s-maxage + stale-while-revalidate). Тело ответа привязано к tenant и никогда не шарится между tenant’ами.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"Стратегия без рефакторинга: Read Model GeoIndex\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"Мы не можем сразу перестроить существующие таблицы контента. Поэтому добавляем отдельный GeoIndex, который можно независимо пересобирать. Он хранит минимум, необходимый для эффективного discovery: tenantId, entityId, entityType, countryCode, citySlug, lat, lng и несколько полей фильтрации. Гидратация получает полный объект из текущего источника.\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Компромисс: eventual consistency. Задержка пересборки индекса приемлема для discovery-страниц. SLA: обновления появляются в течение 15 минут. Если позже потребуется real-time (доступность бронирований), это будет другая поверхность и она не должна использовать discovery-кэш.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API-поверхность\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Две поверхности: (A) HTML-страницы для SEO и пользователей, (B) JSON API для клиентского рендеринга и будущих расширений. Тот же resolver и тот же query plan. Разные presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (внутренний): POST \u002Finternal\u002Fgeoindex\u002Frebuild (защищён), POST \u002Finternal\u002Fgeoindex\u002Fupsert (опционально, позже)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Масштабируемость и производительность\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Основная цель по производительности: отдавать HTML discovery из CDN для популярных geo-лендингов (страна\u002Fгород).\",\"Страницы near кэшируемы, но имеют больше вариантов (cellId + r + q + category + sort + page). Мы ограничиваем r и pageSize и строго валидируем фильтры.\",\"Запросы к GeoIndex должны быть быстрыми: индексы по tenantId + countryCode + citySlug; для near — использовать cell buckets (совпадение по префиксу), затем уточнять расстояние в приложении.\",\"Избегать дорогих SQL-haversine вычислений на больших наборах данных. Это выглядит просто. Это не так.\",\"Гидратация батчится по списку entityId: один запрос на entityType, а не N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Стратегия поиска по радиусу (страницы near)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"Мы не выполняем полный радиусный скан по всем строкам. Вместо этого: (1) cellId сопоставляется с ограничивающим bucket’ом (префикс в стиле geohash), (2) получаем кандидатов из GeoIndex по префиксу bucket’а, (3) уточняем расстояние в приложении с помощью haversine, (4) сортируем и пагинируем. Это делает нагрузку на БД предсказуемой.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Модель кэширования (CDN + сервер)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"Мы кэшируем на двух уровнях. CDN кэширует полный HTML\u002FJSON для анонимного трафика. Серверный кэш хранит результаты providers, индексированные по GeoQuery.cacheKey. Ключ включает tenant, scope и ограниченные фильтры. Кэшировать нужно не всё. Доступность бронирований будет исключена позже.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"Ключ CDN-кэша варьируется по заголовку Host — это граница tenant.\",\"Ключ серверного кэша явно включает tenantId. Никогда не полагаться на неявный host в памяти процесса.\",\"TTL кэша: страна\u002Fгород — 30–60 минут на CDN (stale-while-revalidate включён). Страницы near — 5 минут.\",\"Мы сохраняем ручной рычаг сброса кэша по tenant (суффикс версии в ключе кэша), используемый при миграциях и неудачных деплоях.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"Детали стабильности SEO\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical-теги всегда указывают на чистый иерархический путь (без querystring).\",\"Robots: при наличии неразрешённых параметров запроса (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr) устанавливается noindex для блокировки мусорных параметров из внешних ссылок.\",\"Пагинация: rel=next\u002Fprev генерируется только для страниц > 1 и если resultCount > pageSize.\",\"Стабильная внутренняя перелинковка: UI всегда генерирует канонические пути; querystring используется только для пользовательских фильтров.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Будущие расширения без раздувания\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"Мы расширяем систему, добавляя providers и presenters, а не перегружая resolver. Бронирование, отзывы и карты живут на страницах сущностей или в отдельных API. Discovery остаётся поверхностью списка и фильтрации. Эта граница строго соблюдается при code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Бронирование: отдельные endpoints \u002Fapi\u002Fbooking. Discovery показывает бейджи доступности только если они закэшированы и не персонализированы.\",\"Отзывы: отдельный сервис\u002Fprovider отзывов. Discovery читает агрегированные рейтинги из GeoIndex (предварительно вычисленные).\",\"Карты: тайлы и маркеры загружаются через \u002Fapi\u002Fdiscovery\u002Fmarkers с агрессивным кэшированием; не встраивать в HTML-ответ, если это ухудшает TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"Одна ошибка (и что мы изменили)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"Мы выпустили первую версию с серверным ключом кэша, в который НЕ входил tenantId. Локально это «работало». На staging всё выглядело нормально. Затем — продакшн: портал A начал показывать листинги портала B на городских страницах. Один и тот же путь, разные tenant’ы. Коллизия кэша. Некрасиво.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Исправление было скучным, но строгим: tenantId стал обязательным в GeoQuery, а построение ключа кэша было перенесено в resolver, чтобы его нельзя было пропустить. Мы также добавили runtime-assertion: если гидратированные сущности содержат другой tenantId, мы выбрасываем ошибку и пропускаем запись в кэш. Намеренно шумно.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan и контракт providers\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan — это простой свитч на основе конфигурации tenant и типа scope. Например: одни tenant’ы используют только SQL; другие — удалённый сервис портала. Одинаковый вход GeoQuery. Одинаковый выход GeoResult. Это делает выкатывание предсказуемым.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: минимальная схема\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Компромиссы (явные)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"Мы жертвуем идеальной точностью ради стабильных URL: страницы near канонизируются по cellId. Два пользователя на расстоянии 300 м могут попасть на одну каноническую страницу. Допустимо.\",\"Мы жертвуем свежестью ради кэшируемости: discovery-страницы не real-time. Обновления индекса могут задерживаться.\",\"Мы избегаем рефакторинга БД сейчас, но платим дополнительным путём записи для поддержки GeoIndex.\",\"Мы избегаем жёсткой связи с маршрутизацией CMS, но теперь владеем отдельным namespace маршрутов (\u002Fdiscover). Это осознанно и это обещание, которое нужно соблюдать.\",\"Мы уточняем расстояние в коде приложения для near-поиска, чтобы избежать дорогой математики в БД, перенося нагрузку CPU на слой приложения, который проще масштабировать горизонтально.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"План внедрения (практический)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Задеплоить resolver и пустые страницы, возвращающие 404, под feature flag. Убедиться, что маршрутизация не конфликтует с CMS catch-all.\",\"Построить job для backfill GeoIndex для одного tenant. Сверить количества с исходными данными. Ожидать расхождения и логировать их.\",\"Включить только страницы стран. Наблюдать за cache hit ratio и crawl-статистикой.\",\"Затем включить страницы городов. Страницы near — последними (они генерируют варианты).\",\"После стабилизации ключей кэша добавить потребителей JSON API (маркеры карт, клиентская фильтрация).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Чек-лист приёмки\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Мульти-tenant изоляция: один и тот же путь на двух хостах никогда не разделяет серверный кэш.\",\"Канонические URL не содержат query-параметров и остаются стабильными при изменении фильтров.\",\"Конфликты CMS slug не влияют на маршруты \u002Fdiscover.\",\"Страницы near не генерируют неограниченных перестановок URL (ограничены r и pageSize).\",\"Контракт provider’а возвращает детерминированную пагинацию и согласованные total.\",\"Пересборка GeoIndex может выполняться без downtime и без длительной блокировки исходных таблиц.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":213,"version":509},1769827200000,[214,218,223,228,242,246,250,255,265,269,275,279,283,292,296,304,308,312,317,321,325,337,341,345,349,353,357,364,368,372,381,385,389,393,397,401,409,413,421,425,429,436,440,444,448,452,456,460,464,469,473,482,486,495,499],{"id":215,"data":216,"type":42},"h1",{"text":217,"level":40},"Geo Discovery: каноническая архитектура, дизайн URL, логика резолвера, API и спецификация масштабируемости",{"id":219,"data":220,"type":222},"p-scope",{"text":221},"Этот документ описывает поверхность географического поиска (страна\u002Fгород\u002Fрадиус) для нескольких порталов (multi-tenant), без немедленного рефакторинга базы данных и без жёсткой связи с маршрутизацией CMS (страница\u002Fпост). Он сохраняет стабильность SEO, остаётся дружественным к кэшу и оставляет пространство для бронирований, отзывов и карт, не превращая приложение в монолит.","paragraph",{"id":224,"data":225,"type":42},"h-goals",{"text":226,"level":227},"Ограничения и не-цели",2,{"id":229,"data":230,"type":241},"list-constraints",{"items":231,"style":240},[232,233,234,235,236,237,238,239],"Multi-tenant: одна и та же кодовая база обслуживает несколько порталов. Tenant влияет на область контента, брендинг и иногда на источник данных.","Географический поиск должен поддерживать: страну, город и поиск по радиусу (вокруг точки).","Без немедленного рефакторинга базы данных: мы не можем прямо сейчас перестроить существующие таблицы в идеальную geo-схему.","Независимость от маршрутизации CMS: geo-страницы не являются «постами» или «страницами». Они не должны блокироваться конфликтами slug CMS.","Стабильность SEO: канонические URL не должны меняться при изменении фильтров или сортировки.","Дружественность к кэшу: CDN и серверный кэш должны иметь предсказуемые ключи. Избегать вариаций на пользователя.","Строгое разделение ответственности: discovery, CMS и резолвинг tenant — это отдельные модули с чёткими границами.","Не-цель (пока): идеальное геокодирование. Мы принимаем один геокодер, одну стратегию нормализации и сохраняем нормализованный результат.","unordered","list",{"id":243,"data":244,"type":42},"h-arch",{"text":245,"level":227},"Каноническая архитектура",{"id":247,"data":248,"type":222},"p-arch",{"text":249},"Мы реализуем географический discovery как отдельный bounded context с минимальной публичной поверхностью: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Response → SEO и метаданные кэша. Маршруты CMS никогда не вызывают discovery. Discovery никогда не вызывает маршрутизацию CMS. Они разделяют только низкоуровневые утилиты (HTTP, кэш, tenant-контекст).",{"id":251,"data":252,"type":42},"h-components",{"text":253,"level":254},"Ключевые компоненты",3,{"id":256,"data":257,"type":241},"list-components",{"items":258,"style":240},[259,260,261,262,263,264],"TenantContext: определяет tenant по заголовку Host (или по явному идентификатору портала во внутренних вызовах).","GeoResolver: парсит и валидирует geo-сегменты URL; формирует нормализованный GeoQuery.","GeoIndex (Read Model): отдельная таблица\u002Fколлекция, которая сопоставляет entityId → lat\u002Flng + tenant scope + минимальные поля для поиска. Это позволяет избежать рефакторинга исходных таблиц БД.","Data Providers: подключаемые источники (например, существующие SQL-таблицы, WordPress API, сервис другого портала), скрытые за интерфейсом.","SEO Router: формирует канонический URL и метаданные (canonical, hreflang при необходимости, robots-флаги).","Слой кэширования: ключи CDN-кэша и серверного кэша с tenant-скоупом и версионированием.",{"id":266,"data":267,"type":42},"h-folder",{"text":268,"level":227},"Структура каталогов",{"id":270,"data":271,"type":274},"code-folder",{"code":272,"language":273},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # discovery entry points (not CMS)\n    pages\u002F\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n","text","code",{"id":276,"data":277,"type":42},"h-url",{"text":278,"level":227},"Дизайн URL (SEO Canonical)",{"id":280,"data":281,"type":222},"p-url",{"text":282},"Мы сохраняем канонический URL строго иерархическим и человекочитаемым. Фильтры и сортировка остаются в querystring, но НЕ влияют на canonical. Поиск по радиусу использует стабильную страницу «near» с canonical, основанным на округлённой координатной ячейке, а не на сырых lat\u002Flng. Это предотвращает бесконечное количество вариантов URL и взрыв кэша. Это осознанный компромисс.",{"id":284,"data":285,"type":241},"list-url",{"items":286,"style":240},[287,288,289,290,291],"Страница страны: \u002Fdiscover\u002F{countryCode} (пример: \u002Fdiscover\u002Fde)","Страница города: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (пример: \u002Fdiscover\u002Fde\u002Fmunich)","Near (радиус): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (пример: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z), где cellId — короткий идентификатор в стиле geohash","Необязательные параметры запроса (не канонические): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant НЕ включён в путь. Tenant определяется хостом (portal-a.tld, portal-b.tld). Внутренние вызовы передают x-tenant-id.",{"id":293,"data":294,"type":42},"h-canonical-rules",{"text":295,"level":254},"Правила каноникализации",{"id":297,"data":298,"type":241},"list-canonical",{"items":299,"style":240},[300,301,302,303],"Страницы страны\u002Fгорода канонизируются на собственный чистый путь (без querystring).","Страницы near канонизируются на \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Canonical формируется на основе округлённой ячейки, а не входных координат.","page=1 исключается из canonical и из генерации внутренних ссылок.","Неподдерживаемые комбинации (например, несоответствие страны) возвращают 404, а не редирект. Цепочки редиректов ухудшали crawl budget при тестировании.",{"id":305,"data":306,"type":42},"h-resolver",{"text":307,"level":227},"Логика резолвера",{"id":309,"data":310,"type":222},"p-resolver",{"text":311},"Вход резолвера — (tenant, pathname, query). Выход — нормализованный GeoQuery с query plan. Доступа к базе данных внутри резолвера нет. Это разделение оказалось критически важным позже и спасло нас от неприятной ошибки кэширования.",{"id":313,"data":314,"type":274},"code-types",{"code":315,"language":316},"export type TenantId = string;\n\nexport type GeoScope =\n  | { kind: 'country'; countryCode: string }\n  | { kind: 'city'; countryCode: string; citySlug: string }\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\n\nexport type GeoFilters = {\n  q?: string;\n  category?: string;\n  sort?: 'relevance' | 'rating' | 'distance';\n  page: number;\n  pageSize: number;\n};\n\nexport type GeoQuery = {\n  tenantId: TenantId;\n  scope: GeoScope;\n  filters: GeoFilters;\n  canonicalPath: string;\n  cacheKey: string;\n};\n","ts",{"id":318,"data":319,"type":274},"code-resolver",{"code":320,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":323,"type":42},"h-flow",{"text":324,"level":227},"Пошаговый поток запроса",{"id":326,"data":327,"type":241},"list-flow",{"items":328,"style":336},[329,330,331,332,333,334,335],"Edge\u002FCDN принимает запрос. Ключ кэша включает host + путь + ограниченные параметры запроса (q, category, sort, page, pageSize, r).","Сервер приложения создаёт TenantContext из заголовка Host. Доступа к БД на этом этапе нет.","GeoResolver парсит URL и формирует GeoQuery с canonicalPath и server cacheKey.","GeoController строит QueryPlan и решает, какие provider’ы вызывать, исходя из конфигурации tenant и типа scope.","Provider выполняет запрос read-model к GeoIndex (быстро), затем гидратирует результаты из существующего источника (SQL БД, CMS API) по entity ID.","Сборщик ответа добавляет SEO-метаданные: canonical, robots, ссылки пагинации.","Сервер устанавливает заголовки кэша (s-maxage + stale-while-revalidate). Тело ответа привязано к tenant и никогда не шарится между tenant’ами.","ordered",{"id":338,"data":339,"type":42},"h-read-model",{"text":340,"level":227},"Стратегия без рефакторинга: Read Model GeoIndex",{"id":342,"data":343,"type":222},"p-read-model",{"text":344},"Мы не можем сразу перестроить существующие таблицы контента. Поэтому добавляем отдельный GeoIndex, который можно независимо пересобирать. Он хранит минимум, необходимый для эффективного discovery: tenantId, entityId, entityType, countryCode, citySlug, lat, lng и несколько полей фильтрации. Гидратация получает полный объект из текущего источника.",{"id":346,"data":347,"type":222},"p-tradeoffs-read-model",{"text":348},"Компромисс: eventual consistency. Задержка пересборки индекса приемлема для discovery-страниц. SLA: обновления появляются в течение 15 минут. Если позже потребуется real-time (доступность бронирований), это будет другая поверхность и она не должна использовать discovery-кэш.",{"id":350,"data":351,"type":42},"h-api",{"text":352,"level":227},"API-поверхность",{"id":354,"data":355,"type":222},"p-api",{"text":356},"Две поверхности: (A) HTML-страницы для SEO и пользователей, (B) JSON API для клиентского рендеринга и будущих расширений. Тот же resolver и тот же query plan. Разные presenters.",{"id":358,"data":359,"type":241},"list-api",{"items":360,"style":240},[361,362,363],"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (внутренний): POST \u002Finternal\u002Fgeoindex\u002Frebuild (защищён), POST \u002Finternal\u002Fgeoindex\u002Fupsert (опционально, позже)",{"id":365,"data":366,"type":274},"code-api-handler",{"code":367,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":370,"type":42},"h-scalability",{"text":371,"level":227},"Масштабируемость и производительность",{"id":373,"data":374,"type":241},"list-scale",{"items":375,"style":240},[376,377,378,379,380],"Основная цель по производительности: отдавать HTML discovery из CDN для популярных geo-лендингов (страна\u002Fгород).","Страницы near кэшируемы, но имеют больше вариантов (cellId + r + q + category + sort + page). Мы ограничиваем r и pageSize и строго валидируем фильтры.","Запросы к GeoIndex должны быть быстрыми: индексы по tenantId + countryCode + citySlug; для near — использовать cell buckets (совпадение по префиксу), затем уточнять расстояние в приложении.","Избегать дорогих SQL-haversine вычислений на больших наборах данных. Это выглядит просто. Это не так.","Гидратация батчится по списку entityId: один запрос на entityType, а не N+1.",{"id":382,"data":383,"type":42},"h-near-strategy",{"text":384,"level":254},"Стратегия поиска по радиусу (страницы near)",{"id":386,"data":387,"type":222},"p-near",{"text":388},"Мы не выполняем полный радиусный скан по всем строкам. Вместо этого: (1) cellId сопоставляется с ограничивающим bucket’ом (префикс в стиле geohash), (2) получаем кандидатов из GeoIndex по префиксу bucket’а, (3) уточняем расстояние в приложении с помощью haversine, (4) сортируем и пагинируем. Это делает нагрузку на БД предсказуемой.",{"id":390,"data":391,"type":274},"code-haversine",{"code":392,"language":316},"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\n  const R = 6371000;\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\n\n  const dLat = toRad(b.lat - a.lat);\n  const dLng = toRad(b.lng - a.lng);\n\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n\n  const sinDLat = Math.sin(dLat \u002F 2);\n  const sinDLng = Math.sin(dLng \u002F 2);\n\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\n\n  return R * c;\n}\n",{"id":394,"data":395,"type":42},"h-cache",{"text":396,"level":227},"Модель кэширования (CDN + сервер)",{"id":398,"data":399,"type":222},"p-cache",{"text":400},"Мы кэшируем на двух уровнях. CDN кэширует полный HTML\u002FJSON для анонимного трафика. Серверный кэш хранит результаты providers, индексированные по GeoQuery.cacheKey. Ключ включает tenant, scope и ограниченные фильтры. Кэшировать нужно не всё. Доступность бронирований будет исключена позже.",{"id":402,"data":403,"type":241},"list-cache",{"items":404,"style":240},[405,406,407,408],"Ключ CDN-кэша варьируется по заголовку Host — это граница tenant.","Ключ серверного кэша явно включает tenantId. Никогда не полагаться на неявный host в памяти процесса.","TTL кэша: страна\u002Fгород — 30–60 минут на CDN (stale-while-revalidate включён). Страницы near — 5 минут.","Мы сохраняем ручной рычаг сброса кэша по tenant (суффикс версии в ключе кэша), используемый при миграциях и неудачных деплоях.",{"id":410,"data":411,"type":42},"h-seo",{"text":412,"level":227},"Детали стабильности SEO",{"id":414,"data":415,"type":241},"list-seo",{"items":416,"style":240},[417,418,419,420],"Canonical-теги всегда указывают на чистый иерархический путь (без querystring).","Robots: при наличии неразрешённых параметров запроса (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr) устанавливается noindex для блокировки мусорных параметров из внешних ссылок.","Пагинация: rel=next\u002Fprev генерируется только для страниц > 1 и если resultCount > pageSize.","Стабильная внутренняя перелинковка: UI всегда генерирует канонические пути; querystring используется только для пользовательских фильтров.",{"id":422,"data":423,"type":42},"h-extensions",{"text":424,"level":227},"Будущие расширения без раздувания",{"id":426,"data":427,"type":222},"p-extensions",{"text":428},"Мы расширяем систему, добавляя providers и presenters, а не перегружая resolver. Бронирование, отзывы и карты живут на страницах сущностей или в отдельных API. Discovery остаётся поверхностью списка и фильтрации. Эта граница строго соблюдается при code review.",{"id":430,"data":431,"type":241},"list-extensions",{"items":432,"style":240},[433,434,435],"Бронирование: отдельные endpoints \u002Fapi\u002Fbooking. Discovery показывает бейджи доступности только если они закэшированы и не персонализированы.","Отзывы: отдельный сервис\u002Fprovider отзывов. Discovery читает агрегированные рейтинги из GeoIndex (предварительно вычисленные).","Карты: тайлы и маркеры загружаются через \u002Fapi\u002Fdiscovery\u002Fmarkers с агрессивным кэшированием; не встраивать в HTML-ответ, если это ухудшает TTFB.",{"id":437,"data":438,"type":42},"h-went-wrong",{"text":439,"level":227},"Одна ошибка (и что мы изменили)",{"id":441,"data":442,"type":222},"p-wrong",{"text":443},"Мы выпустили первую версию с серверным ключом кэша, в который НЕ входил tenantId. Локально это «работало». На staging всё выглядело нормально. Затем — продакшн: портал A начал показывать листинги портала B на городских страницах. Один и тот же путь, разные tenant’ы. Коллизия кэша. Некрасиво.",{"id":445,"data":446,"type":222},"p-fix",{"text":447},"Исправление было скучным, но строгим: tenantId стал обязательным в GeoQuery, а построение ключа кэша было перенесено в resolver, чтобы его нельзя было пропустить. Мы также добавили runtime-assertion: если гидратированные сущности содержат другой tenantId, мы выбрасываем ошибку и пропускаем запись в кэш. Намеренно шумно.",{"id":449,"data":450,"type":42},"h-query-plan",{"text":451,"level":227},"Query Plan и контракт providers",{"id":453,"data":454,"type":274},"code-provider",{"code":455,"language":316},"import type { GeoQuery } from '..\u002FGeoQuery';\n\nexport type GeoHit = {\n  entityId: string;\n  entityType: 'place' | 'service' | 'listing';\n  lat: number;\n  lng: number;\n  citySlug?: string;\n  countryCode: string;\n  score?: number;\n  distanceMeters?: number;\n};\n\nexport type GeoResult = {\n  hits: GeoHit[];\n  total: number;\n  page: number;\n  pageSize: number;\n};\n\nexport interface GeoProvider {\n  search(query: GeoQuery): Promise\u003CGeoResult>;\n}\n",{"id":457,"data":458,"type":222},"p-queryplan",{"text":459},"QueryPlan — это простой свитч на основе конфигурации tenant и типа scope. Например: одни tenant’ы используют только SQL; другие — удалённый сервис портала. Одинаковый вход GeoQuery. Одинаковый выход GeoResult. Это делает выкатывание предсказуемым.",{"id":461,"data":462,"type":42},"h-sql",{"text":463,"level":254},"SQL GeoIndex: минимальная схема",{"id":465,"data":466,"type":274},"code-sql",{"code":467,"language":468},"CREATE TABLE geo_index (\n  tenant_id     TEXT NOT NULL,\n  entity_id     TEXT NOT NULL,\n  entity_type   TEXT NOT NULL,\n  country_code  TEXT NOT NULL,\n  city_slug     TEXT,\n  lat           DOUBLE PRECISION NOT NULL,\n  lng           DOUBLE PRECISION NOT NULL,\n  cell_prefix   TEXT NOT NULL,\n  category      TEXT,\n  rating_avg    DOUBLE PRECISION,\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (tenant_id, entity_id)\n);\n\nCREATE INDEX geo_index_country_city\n  ON geo_index (tenant_id, country_code, city_slug);\n\nCREATE INDEX geo_index_cell_prefix\n  ON geo_index (tenant_id, country_code, cell_prefix);\n","sql",{"id":470,"data":471,"type":42},"h-tradeoffs",{"text":472,"level":227},"Компромиссы (явные)",{"id":474,"data":475,"type":241},"list-tradeoffs",{"items":476,"style":240},[477,478,479,480,481],"Мы жертвуем идеальной точностью ради стабильных URL: страницы near канонизируются по cellId. Два пользователя на расстоянии 300 м могут попасть на одну каноническую страницу. Допустимо.","Мы жертвуем свежестью ради кэшируемости: discovery-страницы не real-time. Обновления индекса могут задерживаться.","Мы избегаем рефакторинга БД сейчас, но платим дополнительным путём записи для поддержки GeoIndex.","Мы избегаем жёсткой связи с маршрутизацией CMS, но теперь владеем отдельным namespace маршрутов (\u002Fdiscover). Это осознанно и это обещание, которое нужно соблюдать.","Мы уточняем расстояние в коде приложения для near-поиска, чтобы избежать дорогой математики в БД, перенося нагрузку CPU на слой приложения, который проще масштабировать горизонтально.",{"id":483,"data":484,"type":42},"h-rollout",{"text":485,"level":227},"План внедрения (практический)",{"id":487,"data":488,"type":241},"list-rollout",{"items":489,"style":336},[490,491,492,493,494],"Задеплоить resolver и пустые страницы, возвращающие 404, под feature flag. Убедиться, что маршрутизация не конфликтует с CMS catch-all.","Построить job для backfill GeoIndex для одного tenant. Сверить количества с исходными данными. Ожидать расхождения и логировать их.","Включить только страницы стран. Наблюдать за cache hit ratio и crawl-статистикой.","Затем включить страницы городов. Страницы near — последними (они генерируют варианты).","После стабилизации ключей кэша добавить потребителей JSON API (маркеры карт, клиентская фильтрация).",{"id":496,"data":497,"type":42},"h-acceptance",{"text":498,"level":227},"Чек-лист приёмки",{"id":500,"data":501,"type":241},"list-acceptance",{"items":502,"style":240},[503,504,505,506,507,508],"Мульти-tenant изоляция: один и тот же путь на двух хостах никогда не разделяет серверный кэш.","Канонические URL не содержат query-параметров и остаются стабильными при изменении фильтров.","Конфликты CMS slug не влияют на маршруты \u002Fdiscover.","Страницы near не генерируют неограниченных перестановок URL (ограничены r и pageSize).","Контракт provider’а возвращает детерминированную пагинацию и согласованные total.","Пересборка GeoIndex может выполняться без downtime и без длительной блокировки исходных таблиц.","2.29.1","Геоориентированная архитектура обнаружения для мультитенантных порталов. Определяет канонические URL-адреса, логику разрешения, стратегию кэширования и гео-модель чтения без привязки к CMS или рефакторинга базы данных. Разработано для стабильности SEO, масштабируемости и будущих расширений, таких как бронирование и карты.","\u002Fuploads\u002F2026\u002F01\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp.webp","canonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp","PUBLISHED","2026-01-31T06:12:00.000Z","2026-01-31T20:12:05.337Z","2026-02-20T20:40:40.678Z",{"en":518,"de":519,"sr":520,"es":521,"fr":522,"it":523,"ru":524,"zh":525},"\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fde\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fsr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fes\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Ffr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fit\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fru\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fzh\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification",[527,531,535,539,543],{"id":528,"name":529,"slug":530},108,"Оценка поставки","delivery-assessment",{"id":532,"name":533,"slug":534},107,"Оценки","assessments",{"id":536,"name":537,"slug":538},45,"Референсная модель: Цифровая платформа","digital-platform",{"id":540,"name":541,"slug":542},66,"Операции контента","content-ops",{"id":544,"name":545,"slug":546},79,"Плейбук: Усиление безопасности","security-hardening",{"id":548,"login":549,"email":550,"displayName":551},"20","rooth8233","","Aleksandar Stajić",[553,783],{"lang":554,"title":555,"content":556,"contentJson":557,"excerpt":782},"en","Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints and Non-Goals\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.\",\"Geo discovery must support: country, city, radius search (around a point).\",\"No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.\",\"Independence from CMS routing: geo pages are not \\\"posts\\\" or \\\"pages\\\". They cannot be blocked by CMS slug conflicts.\",\"SEO stability: canonical URLs must not change when filters\u002Fsort options change.\",\"Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.\",\"Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.\",\"Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Canonical Architecture\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Key Components\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).\",\"GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.\",\"GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.\",\"Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.\",\"SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).\",\"Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Folder Structure\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \\\"near\\\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)\",\"City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier\",\"Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical Rules\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Country\u002Fcity pages canonicalize to their own clean path (no querystring).\",\"Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.\",\"page=1 is omitted from canonical and from internal link generation.\",\"Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver Logic\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Step-by-Step Request Flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).\",\"App server creates TenantContext from Host header. No DB yet.\",\"GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.\",\"GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.\",\"Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).\",\"Response assembler attaches SEO metadata: canonical, robots, pagination links.\",\"Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor Strategy: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API Surface\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalability & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).\",\"Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.\",\"GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.\",\"Avoid expensive SQL haversine over large datasets. It looks simple. It is not.\",\"Hydration is batched by entityId list. One query per entityType, not N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius Search Strategy (Near Pages)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching Model (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN cache key varies by Host header. That is the tenant boundary.\",\"Server cache key includes tenantId explicitly. Never rely on implicit host in-process.\",\"Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.\",\"We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO Stability Details\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical tags: always point to the clean hierarchical path (no querystring).\",\"Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.\",\"Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.\",\"Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Future Extensions Without Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.\",\"Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).\",\"Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"One Thing That Went Wrong (and What We Changed)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan and Provider Contract\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimal Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (Explicit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.\",\"We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.\",\"We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.\",\"We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.\",\"We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout Plan (Practical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.\",\"Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.\",\"Enable country pages only. Watch cache hit ratio and crawl stats.\",\"Enable city pages next. Then near pages last (they are the variant generator).\",\"After we see stable cache keys, add JSON API consumers (maps markers, client filtering).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Acceptance Checklist\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Multi-tenant isolation: same path on two hosts never shares server cache entries.\",\"Canonical URLs do not include query params and remain stable across filter changes.\",\"CMS slug conflicts do not affect \u002Fdiscover routes.\",\"Near pages do not generate unbounded URL permutations (bounded r, pageSize).\",\"Provider contract returns deterministic paging and total counts.\",\"GeoIndex rebuild can run without downtime and without locking source tables for long periods.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":558,"version":509},[559,562,565,568,579,582,585,588,597,600,602,605,608,616,619,626,629,632,634,637,640,650,653,656,659,662,665,669,672,675,683,686,689,691,694,697,704,707,714,717,720,726,729,732,735,738,740,743,746,748,751,759,762,770,773],{"id":215,"data":560,"type":42},{"text":561,"level":40},"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec",{"id":219,"data":563,"type":222},{"text":564},"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.",{"id":224,"data":566,"type":42},{"text":567,"level":227},"Constraints and Non-Goals",{"id":229,"data":569,"type":241},{"items":570,"style":240},[571,572,573,574,575,576,577,578],"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.","Geo discovery must support: country, city, radius search (around a point).","No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.","Independence from CMS routing: geo pages are not \"posts\" or \"pages\". They cannot be blocked by CMS slug conflicts.","SEO stability: canonical URLs must not change when filters\u002Fsort options change.","Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.","Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.","Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.",{"id":243,"data":580,"type":42},{"text":581,"level":227},"Canonical Architecture",{"id":247,"data":583,"type":222},{"text":584},"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).",{"id":251,"data":586,"type":42},{"text":587,"level":254},"Key Components",{"id":256,"data":589,"type":241},{"items":590,"style":240},[591,592,593,594,595,596],"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).","GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.","GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.","Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.","SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).","Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.",{"id":266,"data":598,"type":42},{"text":599,"level":227},"Folder Structure",{"id":270,"data":601,"type":274},{"code":272,"language":273},{"id":276,"data":603,"type":42},{"text":604,"level":227},"URL Design (SEO Canonical)",{"id":280,"data":606,"type":222},{"text":607},"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \"near\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.",{"id":284,"data":609,"type":241},{"items":610,"style":240},[611,612,613,614,615],"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)","City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier","Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.",{"id":293,"data":617,"type":42},{"text":618,"level":254},"Canonical Rules",{"id":297,"data":620,"type":241},{"items":621,"style":240},[622,623,624,625],"Country\u002Fcity pages canonicalize to their own clean path (no querystring).","Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.","page=1 is omitted from canonical and from internal link generation.","Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.",{"id":305,"data":627,"type":42},{"text":628,"level":227},"Resolver Logic",{"id":309,"data":630,"type":222},{"text":631},"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.",{"id":313,"data":633,"type":274},{"code":315,"language":316},{"id":318,"data":635,"type":274},{"code":636,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F radius is NOT in the path, but is bounded.\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\n  \u002F\u002F But we keep it bounded and explicit.\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":638,"type":42},{"text":639,"level":227},"Step-by-Step Request Flow",{"id":326,"data":641,"type":241},{"items":642,"style":336},[643,644,645,646,647,648,649],"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).","App server creates TenantContext from Host header. No DB yet.","GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.","GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.","Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).","Response assembler attaches SEO metadata: canonical, robots, pagination links.","Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.",{"id":338,"data":651,"type":42},{"text":652,"level":227},"No-Refactor Strategy: GeoIndex Read Model",{"id":342,"data":654,"type":222},{"text":655},"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).",{"id":346,"data":657,"type":222},{"text":658},"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.",{"id":350,"data":660,"type":42},{"text":661,"level":227},"API Surface",{"id":354,"data":663,"type":222},{"text":664},"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.",{"id":358,"data":666,"type":241},{"items":667,"style":240},[361,362,668],"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":365,"data":670,"type":274},{"code":671,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\n  \u002F\u002F This keeps logic aligned between HTML and API.\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":673,"type":42},{"text":674,"level":227},"Scalability & Performance",{"id":373,"data":676,"type":241},{"items":677,"style":240},[678,679,680,681,682],"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).","Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.","GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.","Avoid expensive SQL haversine over large datasets. It looks simple. It is not.","Hydration is batched by entityId list. One query per entityType, not N+1.",{"id":382,"data":684,"type":42},{"text":685,"level":254},"Radius Search Strategy (Near Pages)",{"id":386,"data":687,"type":222},{"text":688},"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.",{"id":390,"data":690,"type":274},{"code":392,"language":316},{"id":394,"data":692,"type":42},{"text":693,"level":227},"Caching Model (CDN + Server)",{"id":398,"data":695,"type":222},{"text":696},"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.",{"id":402,"data":698,"type":241},{"items":699,"style":240},[700,701,702,703],"CDN cache key varies by Host header. That is the tenant boundary.","Server cache key includes tenantId explicitly. Never rely on implicit host in-process.","Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.","We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.",{"id":410,"data":705,"type":42},{"text":706,"level":227},"SEO Stability Details",{"id":414,"data":708,"type":241},{"items":709,"style":240},[710,711,712,713],"Canonical tags: always point to the clean hierarchical path (no querystring).","Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.","Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.","Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.",{"id":422,"data":715,"type":42},{"text":716,"level":227},"Future Extensions Without Bloat",{"id":426,"data":718,"type":222},{"text":719},"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.",{"id":430,"data":721,"type":241},{"items":722,"style":240},[723,724,725],"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.","Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).","Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.",{"id":437,"data":727,"type":42},{"text":728,"level":227},"One Thing That Went Wrong (and What We Changed)",{"id":441,"data":730,"type":222},{"text":731},"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.",{"id":445,"data":733,"type":222},{"text":734},"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.",{"id":449,"data":736,"type":42},{"text":737,"level":227},"Query Plan and Provider Contract",{"id":453,"data":739,"type":274},{"code":455,"language":316},{"id":457,"data":741,"type":222},{"text":742},"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.",{"id":461,"data":744,"type":42},{"text":745,"level":254},"SQL GeoIndex: Minimal Schema",{"id":465,"data":747,"type":274},{"code":467,"language":468},{"id":470,"data":749,"type":42},{"text":750,"level":227},"Trade-offs (Explicit)",{"id":474,"data":752,"type":241},{"items":753,"style":240},[754,755,756,757,758],"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.","We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.","We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.","We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.","We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.",{"id":483,"data":760,"type":42},{"text":761,"level":227},"Rollout Plan (Practical)",{"id":487,"data":763,"type":241},{"items":764,"style":336},[765,766,767,768,769],"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.","Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.","Enable country pages only. Watch cache hit ratio and crawl stats.","Enable city pages next. Then near pages last (they are the variant generator).","After we see stable cache keys, add JSON API consumers (maps markers, client filtering).",{"id":496,"data":771,"type":42},{"text":772,"level":227},"Acceptance Checklist",{"id":500,"data":774,"type":241},{"items":775,"style":240},[776,777,778,779,780,781],"Multi-tenant isolation: same path on two hosts never shares server cache entries.","Canonical URLs do not include query params and remain stable across filter changes.","CMS slug conflicts do not affect \u002Fdiscover routes.","Near pages do not generate unbounded URL permutations (bounded r, pageSize).","Provider contract returns deterministic paging and total counts.","GeoIndex rebuild can run without downtime and without locking source tables for long periods.","Geo-based discovery architecture for multi-tenant portals. Defines canonical URLs, resolver logic, caching strategy, and a geo read-model without CMS coupling or database refactoring. Designed for SEO stability, scalability, and future extensions like booking and maps.",{"lang":7,"title":208,"content":210,"contentJson":784,"excerpt":510},{"time":212,"blocks":785,"version":509},[786,788,790,792,795,797,799,801,804,806,808,810,812,815,817,820,822,824,826,828,830,833,835,837,839,841,843,846,848,850,853,855,857,859,861,863,866,868,871,873,875,878,880,882,884,886,888,890,892,894,896,899,901,904,906],{"id":215,"data":787,"type":42},{"text":217,"level":40},{"id":219,"data":789,"type":222},{"text":221},{"id":224,"data":791,"type":42},{"text":226,"level":227},{"id":229,"data":793,"type":241},{"items":794,"style":240},[232,233,234,235,236,237,238,239],{"id":243,"data":796,"type":42},{"text":245,"level":227},{"id":247,"data":798,"type":222},{"text":249},{"id":251,"data":800,"type":42},{"text":253,"level":254},{"id":256,"data":802,"type":241},{"items":803,"style":240},[259,260,261,262,263,264],{"id":266,"data":805,"type":42},{"text":268,"level":227},{"id":270,"data":807,"type":274},{"code":272,"language":273},{"id":276,"data":809,"type":42},{"text":278,"level":227},{"id":280,"data":811,"type":222},{"text":282},{"id":284,"data":813,"type":241},{"items":814,"style":240},[287,288,289,290,291],{"id":293,"data":816,"type":42},{"text":295,"level":254},{"id":297,"data":818,"type":241},{"items":819,"style":240},[300,301,302,303],{"id":305,"data":821,"type":42},{"text":307,"level":227},{"id":309,"data":823,"type":222},{"text":311},{"id":313,"data":825,"type":274},{"code":315,"language":316},{"id":318,"data":827,"type":274},{"code":320,"language":316},{"id":322,"data":829,"type":42},{"text":324,"level":227},{"id":326,"data":831,"type":241},{"items":832,"style":336},[329,330,331,332,333,334,335],{"id":338,"data":834,"type":42},{"text":340,"level":227},{"id":342,"data":836,"type":222},{"text":344},{"id":346,"data":838,"type":222},{"text":348},{"id":350,"data":840,"type":42},{"text":352,"level":227},{"id":354,"data":842,"type":222},{"text":356},{"id":358,"data":844,"type":241},{"items":845,"style":240},[361,362,363],{"id":365,"data":847,"type":274},{"code":367,"language":316},{"id":369,"data":849,"type":42},{"text":371,"level":227},{"id":373,"data":851,"type":241},{"items":852,"style":240},[376,377,378,379,380],{"id":382,"data":854,"type":42},{"text":384,"level":254},{"id":386,"data":856,"type":222},{"text":388},{"id":390,"data":858,"type":274},{"code":392,"language":316},{"id":394,"data":860,"type":42},{"text":396,"level":227},{"id":398,"data":862,"type":222},{"text":400},{"id":402,"data":864,"type":241},{"items":865,"style":240},[405,406,407,408],{"id":410,"data":867,"type":42},{"text":412,"level":227},{"id":414,"data":869,"type":241},{"items":870,"style":240},[417,418,419,420],{"id":422,"data":872,"type":42},{"text":424,"level":227},{"id":426,"data":874,"type":222},{"text":428},{"id":430,"data":876,"type":241},{"items":877,"style":240},[433,434,435],{"id":437,"data":879,"type":42},{"text":439,"level":227},{"id":441,"data":881,"type":222},{"text":443},{"id":445,"data":883,"type":222},{"text":447},{"id":449,"data":885,"type":42},{"text":451,"level":227},{"id":453,"data":887,"type":274},{"code":455,"language":316},{"id":457,"data":889,"type":222},{"text":459},{"id":461,"data":891,"type":42},{"text":463,"level":254},{"id":465,"data":893,"type":274},{"code":467,"language":468},{"id":470,"data":895,"type":42},{"text":472,"level":227},{"id":474,"data":897,"type":241},{"items":898,"style":240},[477,478,479,480,481],{"id":483,"data":900,"type":42},{"text":485,"level":227},{"id":487,"data":902,"type":241},{"items":903,"style":336},[490,491,492,493,494],{"id":496,"data":905,"type":42},{"text":498,"level":227},{"id":500,"data":907,"type":241},{"items":908,"style":240},[503,504,505,506,507,508],"Post erfolgreich abgerufen",{"items":911,"source":947,"manualIds":948,"manualMatchedIds":949},[912,919,926,933,940],{"id":913,"slug":914,"title":915,"excerpt":916,"featuredImage":917,"publishedAt":918},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA в ZBT Z8102AX: диапазоны 5G, o2 Germany и поведение сигнала в реальных условиях","ZBT Z8102AX использует модем Quectel RM500U-EA для подключения 4G и 5G. В первом практическом тесте роутер успешно подключился к o2 Germany с LTE Band 3 и NR n28. Модем работает, но более глубокая диагностика, такая как RSRP, RSRQ, SINR, блокировка диапазонов и поведение сот, все еще требует надлежащего тестирования.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z",{"id":920,"slug":921,"title":922,"excerpt":923,"featuredImage":924,"publishedAt":925},"361","model-view-controller-mvc","Модель-Представление-Контроллер (MVC): Структурная основа современных веб-приложений","Model-View-Controller, обычно сокращаемый до MVC, остается одним из самых долговечных архитектурных паттернов в разработке программного обеспечения. Он предоставляет командам практичный способ разделения бизнес-логики, представления и взаимодействия с пользователем, благодаря чему приложения легче создавать, расширять, тестировать и поддерживать. В этой статье объясняется, что такое MVC, почему он по-прежнему важен, как он вписывается в современные веб-стеки и как он связан с более широкой архитектурой платформы, качеством поставки, стратегией миграции и операционной зрелостью.","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z",{"id":927,"slug":928,"title":929,"excerpt":930,"featuredImage":931,"publishedAt":932},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","Как узнать, действительно ли ИИ-агент использовал правильные доказательства","ИИ-агент может ссылаться на источники и при этом использовать неверные доказательства. В этой статье представлен практический метод проверки обоснованности утверждений, авторитетности источников, применимости, происхождения и того, действительно ли доказательства повлияли на ответ.","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z",{"id":934,"slug":935,"title":936,"excerpt":937,"featuredImage":938,"publishedAt":939},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","Мультитенантная архитектура корпоративного уровня для международной платформы","Loving Rocks является корпоративной свадебной платформой, разработанной с истинной многоарендной архитектурой, изолированными базами данных для каждого арендатора и встроенной интернационализацией для глобальной масштабируемости, безопасности и долгосрочной операционной стабильности.","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":941,"slug":942,"title":943,"excerpt":944,"featuredImage":945,"publishedAt":946},"456","zbt-z8102ax-hardware-packaging-review","Обзор аппаратной части и упаковки ZBT Z8102AX: мощный роутер, слабая коробка","ZBT Z8102AX производит солидное первое впечатление как тонкий черный металлический 5G-роутер на OpenWrt с несколькими разъемами для антенн, двумя слотами для SIM-карт, портами USB, LAN\u002FWAN и практичным набором аксессуаров. Аппаратная часть кажется полезной и серьезной, но упаковка, очевидно, является слабым местом.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z","fallback",[],[]]