itube-specs 0.0.1050 → 0.0.1052
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SITE_UPDATES.md +33 -0
- package/app.config.ts +4 -0
- package/components/grid/videos.vue +13 -0
- package/components/grid/webcams.vue +16 -4
- package/components/ui/pagination.vue +5 -0
- package/composables/fetch/use-fetch-models-by-letter.ts +5 -0
- package/composables/fetch/use-fetch-models-by-phrases.ts +5 -0
- package/composables/fetch/use-fetch-models.ts +5 -0
- package/composables/page/use-category-page.ts +6 -0
- package/composables/page/use-search-page.ts +2 -0
- package/composables/use-device-id.ts +40 -0
- package/composables/use-meta.ts +1 -0
- package/composables/use-metrika-goal.ts +12 -0
- package/composables/use-search-niche-redirect.ts +44 -0
- package/composables/use-sort-guard.ts +30 -0
- package/composables/use-stats-page.ts +30 -0
- package/composables/use-webcam-stats.ts +45 -0
- package/middleware/normalize.global.ts +6 -1
- package/nuxt.config.ts +24 -2
- package/package.json +1 -1
- package/pages/playlists/[playlistId]/[slug].vue +8 -0
- package/pages/search/[slug]/index.vue +2 -0
- package/pages/search/[slug]/videos.vue +2 -0
- package/pages/tag/[tag].vue +2 -0
- package/pages/videos/[slug].vue +10 -0
- package/plugins/metrika.ts +47 -0
- package/plugins/stats.client.ts +66 -0
- package/runtime/index.ts +3 -0
- package/runtime/utils/__tests__/normalize-url.test.ts +9 -1
- package/runtime/utils/__tests__/page-info.test.ts +32 -0
- package/runtime/utils/__tests__/resolve-sort-query.test.ts +30 -0
- package/runtime/utils/__tests__/rotator-grid.test.ts +22 -0
- package/runtime/utils/normalize-url.ts +5 -0
- package/runtime/utils/page-info.ts +70 -0
- package/runtime/utils/resolve-sort-query.ts +22 -0
- package/runtime/utils/rotator-grid.ts +32 -0
- package/server/api/search/niche.post.ts +19 -0
- package/server/api/stats/rotator.post.ts +11 -0
- package/server/api/stats/user.post.ts +11 -0
- package/server/api/stats/webcams.post.ts +11 -0
- package/services/api/search.service.ts +22 -0
- package/utils/stats/flush.ts +88 -0
- package/utils/stats/rotator.ts +159 -0
- package/utils/stats/service.ts +30 -0
- package/utils/stats/ulogger.ts +67 -0
- package/utils/stats/webcams.ts +76 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,39 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1052 — 2026-10-22
|
|
15
|
+
|
|
16
|
+
- **Всем — к сведению:** мусорные адреса листингов теперь 404. `?page` не натуральное число (`0`, `-1`, `abc`, `01`) —
|
|
17
|
+
404 в `normalize.global.ts`; `?sort`, которого нет у страницы, — 404 (`useSortGuard`, его зовёт `useMeta`, у страницы
|
|
18
|
+
категории без `metaKeys` — `useCategoryPage`); `?sort` у страницы без сортировок — тоже 404; модели за последней
|
|
19
|
+
страницей (API отвечает ошибкой) — 404; пустая пагинация за последней страницей (`UiPagination` с `total = 0`) —
|
|
20
|
+
404. Значение `?sort`, совпадающее с ключом API сортировки страницы (`-created`, `rank`, `-updated`), — 301 на неё
|
|
21
|
+
(сортировка по умолчанию — без `sort`). Своя страница со своими сортировками — передавать их в `useMeta`
|
|
22
|
+
(`sortOptions`), иначе её `?sort` станет 404.
|
|
23
|
+
- **Сайтам, переехавшим со старого движка (videosection-redesign, hdsex-remaster, gay-cool-new):** новый ключ
|
|
24
|
+
`appConfig.legacySorts` (через `config/site.config.ts`) — старые значения `?sort`, которых нет у страниц или они под
|
|
25
|
+
другим ключом: старое → ключ API (`''` — аналога нет, 301 на страницу без сортировки). Без карты такие адреса из
|
|
26
|
+
индекса (`?sort=-views`, `?sort=-videos_count`…) станут 404. Образец — коммит videosection-redesign с этим изменением.
|
|
27
|
+
- **Сайтам с поиском по нишам (videosection-redesign, hdsex-remaster):** фича-флаг `FeatureSearchNicheRedirectEnabled`
|
|
28
|
+
теперь читает слой: поиск во «чужой» нише (`/search/gay-anal` на straight) — 302 в нишу фразы; нишу и очищенную фразу
|
|
29
|
+
отдаёт v2 поиска (`POST /bff/search/niche`), как site-flag `isSearchNicheRedirectEnabled` старого движка. Страницы
|
|
30
|
+
поиска слоя зовут `await useSearchNicheRedirect()` первым; своя страница поиска — добавить тот же вызов до запросов
|
|
31
|
+
выдачи. hdsex-remaster: свою реализацию (`server/api/search/niche.get.ts`, `redirectToNicheWithResults` в
|
|
32
|
+
`pages/search/[slug]/index.vue`) удалить — иначе сработают обе.
|
|
33
|
+
- **Сайтам со своей статистикой (hdsex-remaster):** статистика старого движка теперь в слое под `FeatureStatsEnabled`:
|
|
34
|
+
`plugins/stats.client.ts`, `utils/stats/*`, BFF `/bff/stats/{rotator,user,webcams}`, `useDeviceId`, `useWebcamStats`
|
|
35
|
+
(его зовут `GridWebcams` и врезки `GridVideos` — клики/показы вебкам-тумб уходят без вызова на сайте), `useStatsPage`
|
|
36
|
+
(контекст страницы: видео, ролик плейлиста, категория, тег, поиск зовут его сами — ротатор на видео и категории,
|
|
37
|
+
u-logger там же, где на бою). Тумбы сетки видео получили `data-md5` / `data-thumb-number` / `data-pos-number`.
|
|
38
|
+
hdsex-remaster: удалить свои `plugins/stats.client.ts`, `utils/stats-{flush,service,rotator,ulogger,webcams}.ts`,
|
|
39
|
+
`utils/device-id.ts`, `lib/page-info.ts`, `lib/rotator-grid.ts`, `composables/use-webcam-stats.ts`, ручки
|
|
40
|
+
`server/api/stats/{rotator,user,webcams}.post.ts` и вызовы `useWebcamStats` в своих view — иначе при включённом флаге
|
|
41
|
+
статистика уйдёт дважды. Телеметрию рекламы (`stats-ad-availability`, `PUT /stats/c`) оставить своей. Остальным —
|
|
42
|
+
флаг выключен, ничего не меняется.
|
|
43
|
+
- **Сайтам с Яндекс.Метрикой (videosection-redesign; hdsex-remaster — счётчик 94076295 в бэклоге):** счётчик теперь в
|
|
44
|
+
слое — `plugins/metrika.ts`, id из `runtimeConfig.public.metrikaId` (env `NUXT_PUBLIC_METRIKA_ID`; в `nuxt.config.ts`
|
|
45
|
+
сайта — `metrikaId: process.env.NUXT_PUBLIC_METRIKA_ID || ''`), цели — `useMetrikaGoal()`. Пусто — счётчика нет.
|
|
46
|
+
|
|
14
47
|
## 0.0.1045 — 2026-10-20
|
|
15
48
|
|
|
16
49
|
- **Сайтам, у которых бэк включает хинди (`/v1/site/languages` отдаёт `name: "in"`, `iso: "hi"` — сейчас
|
package/app.config.ts
CHANGED
|
@@ -21,6 +21,10 @@ export default defineAppConfig({
|
|
|
21
21
|
videos: null as { afterRows: number, inserts: number } | null,
|
|
22
22
|
models: null as { afterRows: number } | null,
|
|
23
23
|
},
|
|
24
|
+
/** Старые значения `?sort` → ключ API сортировки редизайна (`''` — аналога нет, 301 на страницу без сортировки).
|
|
25
|
+
* Значения, совпадающие с ключом API сортировки страницы (`-created`, `rank`), узнаются и без карты;
|
|
26
|
+
* прочие неизвестные `?sort` — 404 (`useSortGuard`). */
|
|
27
|
+
legacySorts: {} as Record<string, string>,
|
|
24
28
|
/** Класс кликабельной части карточек в сетках видео и моделей — для сайтовых хуков (триггер попандера). */
|
|
25
29
|
cardTriggerClass: '',
|
|
26
30
|
/** Состав страницы видео. */
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
:playlist-id="playlistId"
|
|
35
35
|
:playlist="playlist"
|
|
36
36
|
:link-class="cardTriggerClass"
|
|
37
|
+
:data-md5="cell.video.md5"
|
|
38
|
+
:data-thumb-number="cell.videoIndex + 1"
|
|
39
|
+
:data-pos-number="cell.videoIndex + 1"
|
|
37
40
|
class="grid-videos__card"
|
|
38
41
|
/>
|
|
39
42
|
</template>
|
|
@@ -65,6 +68,9 @@
|
|
|
65
68
|
:playlist-id="playlistId"
|
|
66
69
|
:playlist="playlist"
|
|
67
70
|
:link-class="cardTriggerClass"
|
|
71
|
+
:data-md5="cell.video.md5"
|
|
72
|
+
:data-thumb-number="cell.videoIndex + 1"
|
|
73
|
+
:data-pos-number="cell.videoIndex + 1"
|
|
68
74
|
class="grid-videos__card"
|
|
69
75
|
loading="lazy"
|
|
70
76
|
hydrate-on-idle
|
|
@@ -89,6 +95,9 @@
|
|
|
89
95
|
:playlist="playlist"
|
|
90
96
|
:hydrate-on-visible="HYDRATE_MARGIN"
|
|
91
97
|
:link-class="cardTriggerClass"
|
|
98
|
+
:data-md5="cell.video.md5"
|
|
99
|
+
:data-thumb-number="cell.videoIndex + 1"
|
|
100
|
+
:data-pos-number="cell.videoIndex + 1"
|
|
92
101
|
class="grid-videos__card"
|
|
93
102
|
loading="lazy"
|
|
94
103
|
/>
|
|
@@ -141,11 +150,15 @@ const emit = defineEmits<{
|
|
|
141
150
|
}>();
|
|
142
151
|
|
|
143
152
|
/** Событие врезки наружу отдаём через хендлер: в шаблоне модель тумбы уже не сузить. */
|
|
153
|
+
const webcamStats = useWebcamStats();
|
|
154
|
+
|
|
144
155
|
function onInsertClick(cell: IGridCell) {
|
|
156
|
+
if (cell.webcam) webcamStats.onModelClick({ model: cell.webcam, index: cell.insertIndex });
|
|
145
157
|
if (cell.webcam) emit('model-click', { model: cell.webcam, index: cell.insertIndex });
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
function onInsertView(cell: IGridCell, element: HTMLElement) {
|
|
161
|
+
if (cell.webcam) webcamStats.onModelView({ model: cell.webcam, index: cell.insertIndex, element });
|
|
149
162
|
if (cell.webcam) emit('model-view', { model: cell.webcam, index: cell.insertIndex, element });
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
>
|
|
16
16
|
<WebcamThumb
|
|
17
17
|
:model="model"
|
|
18
|
-
@click="
|
|
19
|
-
@poster="element =>
|
|
18
|
+
@click="onClick({ model, index })"
|
|
19
|
+
@poster="element => onView({ model, index, element })"
|
|
20
20
|
/>
|
|
21
21
|
</li>
|
|
22
22
|
</ul>
|
|
@@ -30,13 +30,25 @@ defineProps<{
|
|
|
30
30
|
}>();
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Клики и показы
|
|
34
|
-
*
|
|
33
|
+
* Клики и показы тумб: статистика слоя (`useWebcamStats`, при `FeatureStatsEnabled`) и событиями наружу — для
|
|
34
|
+
* своей статистики сайта.
|
|
35
35
|
*/
|
|
36
36
|
const emit = defineEmits<{
|
|
37
37
|
'model-click': [payload: { model: IStripcashModel, index: number }]
|
|
38
38
|
'model-view': [payload: { model: IStripcashModel, index: number, element: HTMLElement }]
|
|
39
39
|
}>();
|
|
40
|
+
|
|
41
|
+
const webcamStats = useWebcamStats();
|
|
42
|
+
|
|
43
|
+
function onClick(payload: { model: IStripcashModel, index: number }) {
|
|
44
|
+
webcamStats.onModelClick(payload);
|
|
45
|
+
emit('model-click', payload);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onView(payload: { model: IStripcashModel, index: number, element: HTMLElement }) {
|
|
49
|
+
webcamStats.onModelView(payload);
|
|
50
|
+
emit('model-view', payload);
|
|
51
|
+
}
|
|
40
52
|
</script>
|
|
41
53
|
|
|
42
54
|
<style lang="scss">
|
|
@@ -180,6 +180,11 @@ watchEffect(() => {
|
|
|
180
180
|
if (activeNumber.value > length.value && props.total > 0) {
|
|
181
181
|
navigateTo(linkTo(length.value));
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
// за последней страницей, а страниц нет вовсе (API отдал пустую выдачу) — увести некуда, адрес мусорный
|
|
185
|
+
if (activeNumber.value > 1 && props.total === 0) {
|
|
186
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
187
|
+
}
|
|
183
188
|
});
|
|
184
189
|
|
|
185
190
|
function linkTo(value: string | number) {
|
|
@@ -48,5 +48,10 @@ export const useFetchModelsByLetter = async (
|
|
|
48
48
|
}
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
+
// за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
|
|
52
|
+
if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
|
|
53
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
return { data, status, refresh };
|
|
52
57
|
};
|
|
@@ -38,5 +38,10 @@ export const useFetchModelsByPhrases = async (
|
|
|
38
38
|
}
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
+
// за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
|
|
42
|
+
if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
|
|
43
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
return { data, status, error };
|
|
42
47
|
};
|
|
@@ -46,5 +46,10 @@ export const useFetchModels = async (
|
|
|
46
46
|
}
|
|
47
47
|
);
|
|
48
48
|
|
|
49
|
+
// за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
|
|
50
|
+
if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
|
|
51
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
return { data, status, refresh };
|
|
50
55
|
};
|
|
@@ -48,6 +48,12 @@ export function useCategoryPage(
|
|
|
48
48
|
|
|
49
49
|
const title = computed(() => categoryInfo.value?.title || '');
|
|
50
50
|
|
|
51
|
+
// статистика: ротатор листинга категории + u-logger, как на старом движке
|
|
52
|
+
useStatsPage(() => ({ rotatorID: 'category', categories: slug.value ? [slug.value] : [] }));
|
|
53
|
+
|
|
54
|
+
// с metaKeys `?sort` проверяет useMeta
|
|
55
|
+
if (!options.metaKeys) useSortGuard(sortItemsDefault);
|
|
56
|
+
|
|
51
57
|
const keysMeta = options.metaKeys
|
|
52
58
|
? useMeta({
|
|
53
59
|
t,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Идентификатор устройства для статистики — кука `itube-uid` на год.
|
|
3
|
+
*
|
|
4
|
+
* Тот же ключ и тот же алфавит, что на боевом движке (там `nanoid(42)` из `1234567890qwertyuiopasdfghjklzxcvbnm`):
|
|
5
|
+
* статистика склеивается по этому id, поэтому формат менять нельзя. Кука ставится на базовый домен с точкой, чтобы
|
|
6
|
+
* языковые поддомены считались одним устройством.
|
|
7
|
+
*/
|
|
8
|
+
const COOKIE_NAME = 'itube-uid';
|
|
9
|
+
const ALPHABET = '1234567890qwertyuiopasdfghjklzxcvbnm';
|
|
10
|
+
const LENGTH = 42;
|
|
11
|
+
const YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
12
|
+
|
|
13
|
+
function generate(): string {
|
|
14
|
+
const bytes = new Uint8Array(LENGTH);
|
|
15
|
+
crypto.getRandomValues(bytes);
|
|
16
|
+
|
|
17
|
+
return Array.from(bytes, byte => ALPHABET[byte % ALPHABET.length]).join('');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Возвращает id устройства, при первом заходе создавая его. */
|
|
21
|
+
export function useDeviceId(): string {
|
|
22
|
+
// домен куки ставим только если реально находимся на нём: иначе (стенд на localhost, превью-домен) браузер
|
|
23
|
+
// молча выбрасывает куку и id теряется
|
|
24
|
+
const configured = useRuntimeConfig().public.xDomain as string | undefined;
|
|
25
|
+
const host = import.meta.client ? location.hostname : '';
|
|
26
|
+
const domain = configured && host.endsWith(configured) ? configured : undefined;
|
|
27
|
+
|
|
28
|
+
const cookie = useCookie<string | null>(COOKIE_NAME, {
|
|
29
|
+
maxAge: YEAR_SECONDS,
|
|
30
|
+
path: '/',
|
|
31
|
+
domain: domain ? `.${domain}` : undefined,
|
|
32
|
+
sameSite: 'lax',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!cookie.value && import.meta.client) {
|
|
36
|
+
cookie.value = generate();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return cookie.value || '';
|
|
40
|
+
}
|
package/composables/use-meta.ts
CHANGED
|
@@ -63,6 +63,7 @@ type MetaParams = {
|
|
|
63
63
|
*/
|
|
64
64
|
export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, secondText, thirdText, fourthText, h1Key, seo, nicheSafeText }: MetaParams) {
|
|
65
65
|
const route = useRoute();
|
|
66
|
+
useSortGuard(sortOptions);
|
|
66
67
|
const { te, locale } = useI18n();
|
|
67
68
|
const defaultNiche = NicheHelper.getSiteDefaultNiche();
|
|
68
69
|
// стейт берём здесь, в setup: computed читается и вне контекста Nuxt (после await, в useHead)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Цель Яндекс.Метрики сайта: `const reachGoal = useMetrikaGoal()` в setup, `reachGoal('adv.click', params)` в
|
|
3
|
+
* обработчике. Счётчика нет (`metrikaId` пуст) или тег ещё не встал — тихо ничего.
|
|
4
|
+
*/
|
|
5
|
+
export function useMetrikaGoal() {
|
|
6
|
+
const id = Number(useRuntimeConfig().public.metrikaId);
|
|
7
|
+
|
|
8
|
+
return (goal: string, params?: Record<string, unknown>) => {
|
|
9
|
+
const ym = import.meta.client ? (window as unknown as { ym?: (...args: unknown[]) => void }).ym : undefined;
|
|
10
|
+
if (id && typeof ym === 'function') ym(id, 'reachGoal', goal, params);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Language } from '../runtime';
|
|
2
|
+
import { convertString } from '../runtime';
|
|
3
|
+
import { nichePath, withNichePrefix, type NicheSlugs } from '../config/niches';
|
|
4
|
+
import { SearchApiService } from '../services/api/search.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Поиск во «чужой» нише (`/search/gay-anal` на straight) — 302 в нишу фразы, как site-flag
|
|
8
|
+
* `isSearchNicheRedirectEnabled` старого движка: нишу и очищенную фразу определяет v2 поиска
|
|
9
|
+
* (`SearchApiService.getQueryNiche`). Включается `FeatureSearchNicheRedirectEnabled`; звать первым `await` страницы
|
|
10
|
+
* поиска — до запросов выдачи.
|
|
11
|
+
*/
|
|
12
|
+
export async function useSearchNicheRedirect() {
|
|
13
|
+
if (!useRuntimeConfig().public.featureFlags.FeatureSearchNicheRedirectEnabled) return;
|
|
14
|
+
|
|
15
|
+
// всё, что зависит от инстанса Nuxt, — до await: после него контекст потерян
|
|
16
|
+
const nuxtApp = useNuxtApp();
|
|
17
|
+
const slug = useSlug();
|
|
18
|
+
const lang = useLang() as Language;
|
|
19
|
+
const localePath = useLocalePath();
|
|
20
|
+
const appConfig = useAppConfig();
|
|
21
|
+
const currentNiche = NicheHelper.getCurrentNiche();
|
|
22
|
+
const nicheRouting = {
|
|
23
|
+
niches: appConfig.niches as string[],
|
|
24
|
+
defaultNiche: NicheHelper.getSiteDefaultNiche(),
|
|
25
|
+
slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const { data } = await useAsyncData(
|
|
29
|
+
`search-niche-${currentNiche}-${lang}-${slug.value}`,
|
|
30
|
+
() => SearchApiService.getQueryNiche(lang, slug.value, currentNiche).catch(() => null),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const niche = data.value?.niche;
|
|
34
|
+
if (!niche || niche === currentNiche || !nicheRouting.niches.includes(niche)) return;
|
|
35
|
+
|
|
36
|
+
const phrase = data.value?.phrase || slug.value;
|
|
37
|
+
const prefix = nichePath(niche, nicheRouting);
|
|
38
|
+
|
|
39
|
+
await nuxtApp.runWithContext(() => {
|
|
40
|
+
const target = withNichePrefix(localePath(`/search/${encodeURIComponent(convertString().toSlug(phrase))}`), prefix, localePath('/'));
|
|
41
|
+
|
|
42
|
+
return navigateTo(target, { redirectCode: 302, replace: true });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveSortQuery } from '../runtime';
|
|
2
|
+
import type { INavLinkItem } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `?sort` страницы против её сортировок (`resolveSortQuery`): мусор — 404, старые значения
|
|
6
|
+
* (`appConfig.legacySorts`, ключи API) — 301 на аналог или на страницу без сортировки. Зовётся в setup страницы:
|
|
7
|
+
* его вызывает `useMeta`, так что листинги проверены без отдельного вызова.
|
|
8
|
+
*/
|
|
9
|
+
export function useSortGuard(sortOptions?: INavLinkItem[]) {
|
|
10
|
+
const route = useRoute();
|
|
11
|
+
const raw = route.query['sort'];
|
|
12
|
+
if (raw === undefined || raw === null) return;
|
|
13
|
+
|
|
14
|
+
const legacy = (useAppConfig().legacySorts || {}) as Record<string, string>;
|
|
15
|
+
const resolution = resolveSortQuery(String(raw), sortOptions || [], legacy);
|
|
16
|
+
|
|
17
|
+
if (resolution.action === 'notFound') {
|
|
18
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (resolution.action === 'redirect') {
|
|
22
|
+
const { sort: _sort, ...query } = route.query;
|
|
23
|
+
|
|
24
|
+
// в setup не ждём: на сервере ответ-редирект выставляется раньше, чем закончится рендер страницы
|
|
25
|
+
void navigateTo(
|
|
26
|
+
{ path: route.path, query: resolution.sort ? { ...query, sort: resolution.sort } : query },
|
|
27
|
+
{ redirectCode: 301, replace: true },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Что страница сообщает статистике (на старых движках — data-атрибуты скриптов `info.js` и `u-logger.js`). */
|
|
2
|
+
export interface IStatsPageContext {
|
|
3
|
+
/** Включает ротатор на странице (`video`, `category`) — на бою он стоял только там. */
|
|
4
|
+
rotatorID?: string
|
|
5
|
+
categories?: string[]
|
|
6
|
+
tags?: string[]
|
|
7
|
+
models?: string[]
|
|
8
|
+
search?: string
|
|
9
|
+
videoMD5?: string
|
|
10
|
+
videoID?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATE_KEY = 'stats-page';
|
|
14
|
+
|
|
15
|
+
/** Контекст статистики текущей страницы — читает `plugins/stats.client.ts`. */
|
|
16
|
+
export const useStatsPageState = () => useState<IStatsPageContext | null>(STATE_KEY, () => null);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Страница отдаёт статистике свой контекст: без вызова на странице есть только статистика вебкам-тумб, с ним —
|
|
20
|
+
* u-logger (время и интересы) и, с `rotatorID`, ротатор тумб. Звать в setup; данные могут доехать позже — геттер.
|
|
21
|
+
*/
|
|
22
|
+
export function useStatsPage(context: () => IStatsPageContext) {
|
|
23
|
+
if (!useRuntimeConfig().public.featureFlags.FeatureStatsEnabled) return;
|
|
24
|
+
|
|
25
|
+
const state = useStatsPageState();
|
|
26
|
+
|
|
27
|
+
watchEffect(() => {
|
|
28
|
+
state.value = context();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IStripcashModel } from '../types';
|
|
2
|
+
import { resolvePageInfo } from '../runtime';
|
|
3
|
+
import { observeWebcamView, sendWebcamClick, trackWebcamViews } from '../utils/stats/webcams';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Статистика вебкам-тумб для места, где они выводятся (`GridWebcams`, врезки `GridVideos`): клик уходит сразу,
|
|
7
|
+
* показ — по нижней границе тумбы, показы копятся и уезжают одним запросом на уходе со страницы
|
|
8
|
+
* (`utils/stats/webcams.ts`). Без `FeatureStatsEnabled` — пустые обработчики.
|
|
9
|
+
*/
|
|
10
|
+
export const useWebcamStats = () => {
|
|
11
|
+
const noop = { onModelClick: (_payload: { model: IStripcashModel, index: number }) => undefined, onModelView: (_payload: { model: IStripcashModel, index: number, element: HTMLElement }) => undefined };
|
|
12
|
+
if (!useRuntimeConfig().public.featureFlags.FeatureStatsEnabled) return noop;
|
|
13
|
+
|
|
14
|
+
// всё контекстное — в setup: обработчики зовутся из событий DOM, вне инстанса
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const deviceId = useDeviceId();
|
|
17
|
+
|
|
18
|
+
const statsContext = () => {
|
|
19
|
+
const route = router.currentRoute.value;
|
|
20
|
+
const { pageType, pageValue, pageNumber } = resolvePageInfo(route.name, route.params, route.query);
|
|
21
|
+
|
|
22
|
+
return { deviceId, niche: NicheHelper.getCurrentNiche() || 'straight', pageType, pageValue, pageNumber };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const stopHandlers: Array<() => void> = [];
|
|
26
|
+
const username = (model: IStripcashModel) => String(model.username || model.id || '');
|
|
27
|
+
|
|
28
|
+
onMounted(() => {
|
|
29
|
+
stopHandlers.push(trackWebcamViews(statsContext()));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
onBeforeUnmount(() => {
|
|
33
|
+
stopHandlers.forEach(stop => stop());
|
|
34
|
+
stopHandlers.length = 0;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
onModelClick: ({ model, index }: { model: IStripcashModel, index: number }) => {
|
|
39
|
+
sendWebcamClick(statsContext(), username(model), index + 1);
|
|
40
|
+
},
|
|
41
|
+
onModelView: ({ model, index, element }: { model: IStripcashModel, index: number, element: HTMLElement }) => {
|
|
42
|
+
stopHandlers.push(observeWebcamView(element, username(model), index + 1));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Глобальный middleware: нормализует URL (path + query) и делает 301-редирект,
|
|
2
2
|
// если текущий адрес отличается от канонического. Пути из EXCLUDED_PATHS пропускаются.
|
|
3
|
-
import { normalizeUrl } from '../runtime';
|
|
3
|
+
import { isPageQueryValid, normalizeUrl } from '../runtime';
|
|
4
4
|
|
|
5
5
|
const EXCLUDED_PATHS = [
|
|
6
6
|
'/confirm/registration',
|
|
@@ -12,6 +12,11 @@ export default defineNuxtRouteMiddleware((to) => {
|
|
|
12
12
|
|
|
13
13
|
const normalized = normalizeUrl(to);
|
|
14
14
|
|
|
15
|
+
// мусорная пагинация (`?page=0`, `abc`, `-1`) — 404, а не дубль первой страницы
|
|
16
|
+
if (!isPageQueryValid(normalized.query.page)) {
|
|
17
|
+
return abortNavigation(createError({ statusCode: 404, statusMessage: 'Not Found' }));
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
const samePath = normalized.path === to.path;
|
|
16
21
|
const sameQuery = JSON.stringify(normalized.query) === JSON.stringify(to.query);
|
|
17
22
|
|
package/nuxt.config.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { readdirSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { defineNuxtConfig } from 'nuxt/config';
|
|
4
|
+
import { useNuxt } from 'nuxt/kit';
|
|
4
5
|
|
|
5
6
|
// Общие для всех сайтов переводы (навигация, сортировки, UI-действия). `@nuxtjs/i18n`
|
|
6
7
|
// мержит локали слоёв: файл слоя встаёт в `files` ПЕРЕД проектным, поэтому проект
|
|
7
8
|
// перекрывает любой ключ, а `deepCopy` мержит вложенные группы, не заменяя их целиком.
|
|
8
|
-
// Список кодов выводится из самих файлов, поэтому набор здесь —
|
|
9
|
-
//
|
|
9
|
+
// Список кодов выводится из самих файлов, поэтому набор здесь — надмножество локалей всех
|
|
10
|
+
// сайтов. Код, которого нет в locales приложения, i18n добавил бы приложению лишней локалью
|
|
11
|
+
// (без `language` → «ISO code is required to generate alternate link», лишние /<code>/ роуты),
|
|
12
|
+
// поэтому до установки модулей локали слоя урезаются до объявленных приложением.
|
|
10
13
|
const sharedLocalesDir = fileURLToPath(new URL('./i18n/locales', import.meta.url));
|
|
11
14
|
const sharedLocales = readdirSync(sharedLocalesDir)
|
|
12
15
|
.filter(file => file.endsWith('.json'))
|
|
13
16
|
.sort()
|
|
14
17
|
.map(file => ({ code: file.split('-')[0], file }));
|
|
15
18
|
|
|
19
|
+
const localeCode = (locale: string | { code: string }) => typeof locale === 'string' ? locale : locale.code;
|
|
20
|
+
|
|
16
21
|
export default defineNuxtConfig({
|
|
22
|
+
hooks: {
|
|
23
|
+
'modules:before'() {
|
|
24
|
+
const [project, ...layers] = useNuxt().options._layers;
|
|
25
|
+
const appCodes = new Set((project.config.i18n?.locales ?? []).map(localeCode));
|
|
26
|
+
if (!appCodes.size) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const layer of layers) {
|
|
30
|
+
const i18n = layer.config.i18n;
|
|
31
|
+
if (i18n?.locales) {
|
|
32
|
+
i18n.locales = i18n.locales.filter(locale => appCodes.has(localeCode(locale)));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
17
37
|
modules: ['@nuxt/eslint', '@nuxt/icon', '@nuxtjs/i18n'],
|
|
18
38
|
imports: {
|
|
19
39
|
dirs: ['composables/**']
|
|
@@ -78,6 +98,8 @@ export default defineNuxtConfig({
|
|
|
78
98
|
autoRedirect: false,
|
|
79
99
|
},
|
|
80
100
|
seo: {},
|
|
101
|
+
// Счётчик Яндекс.Метрики сайта (`NUXT_PUBLIC_METRIKA_ID`); пусто — счётчика нет (plugins/metrika.ts).
|
|
102
|
+
metrikaId: '',
|
|
81
103
|
}
|
|
82
104
|
},
|
|
83
105
|
});
|
package/package.json
CHANGED
|
@@ -59,6 +59,14 @@ const [
|
|
|
59
59
|
useFetchRelatedSearch(SearchApiService.getPhrases),
|
|
60
60
|
useFetchCategoriesGroupByLetter(AsyncData.GroupsCategoriesByLetter, CategoriesApiService.getListGroupedByLetter),
|
|
61
61
|
]);
|
|
62
|
+
|
|
63
|
+
// u-logger ролика плейлиста (ротатора на бою здесь не было)
|
|
64
|
+
useStatsPage(() => ({
|
|
65
|
+
categories: videoData.value?.categories?.map(item => item.name) || [],
|
|
66
|
+
tags: videoData.value?.tags || [],
|
|
67
|
+
models: videoData.value?.models?.map(item => item.title) || [],
|
|
68
|
+
videoMD5: videoData.value?.md5,
|
|
69
|
+
}));
|
|
62
70
|
const { execute: executePlaylistView } = useApiAction(PlaylistsApiService.setPlaylistView);
|
|
63
71
|
|
|
64
72
|
const id = computed(() => String(route.params[ 'playlistId' ]));
|
|
@@ -119,6 +119,8 @@ import { VideosApiService } from '../../../services/api/videos.service';
|
|
|
119
119
|
|
|
120
120
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
121
121
|
|
|
122
|
+
await useSearchNicheRedirect();
|
|
123
|
+
|
|
122
124
|
const fetchVideos = featureFlags.VectorSearchEnabled
|
|
123
125
|
? useFetchVectorVideosSearch(VideosApiService.getVectorSearch)
|
|
124
126
|
: useFetchVideosSearchByNiche(VideosApiService.getSearchByNiche);
|
package/pages/tag/[tag].vue
CHANGED
|
@@ -52,6 +52,8 @@ const route = useRoute();
|
|
|
52
52
|
const slug = computed(() => String(route.params[ 'tag' ]));
|
|
53
53
|
|
|
54
54
|
const title = convertString().fromSlug(slug.value);
|
|
55
|
+
|
|
56
|
+
useStatsPage(() => ({ tags: [title] }));
|
|
55
57
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
56
58
|
|
|
57
59
|
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
package/pages/videos/[slug].vue
CHANGED
|
@@ -61,6 +61,16 @@ const [
|
|
|
61
61
|
// вторым заходом, а не в общем Promise.all.
|
|
62
62
|
const asideModelsData = await useFetchAsideModels(videoData.value?.models?.map(item => item.title) || []);
|
|
63
63
|
|
|
64
|
+
// статистика страницы видео: ротатор похожих + u-logger, как `info.js` / `u-logger.js` старого движка
|
|
65
|
+
useStatsPage(() => ({
|
|
66
|
+
rotatorID: 'video',
|
|
67
|
+
categories: videoData.value?.categories?.map(item => item.name) || [],
|
|
68
|
+
tags: videoData.value?.tags || [],
|
|
69
|
+
models: videoData.value?.models?.map(item => item.title) || [],
|
|
70
|
+
videoMD5: videoData.value?.md5,
|
|
71
|
+
videoID: videoData.value?.id,
|
|
72
|
+
}));
|
|
73
|
+
|
|
64
74
|
const asideModels = computed(() => asideModelsData.map(item => ({
|
|
65
75
|
title: item.title,
|
|
66
76
|
slug: item.slug,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
type YmFunction = ((...args: unknown[]) => void) & { a?: unknown[], l?: number };
|
|
2
|
+
|
|
3
|
+
const TAG_URL = 'https://mc.yandex.ru/metrika/tag.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Яндекс.Метрика сайта (`runtimeConfig.public.metrikaId`, env `NUXT_PUBLIC_METRIKA_ID`; пусто — счётчика нет).
|
|
7
|
+
* Как на старых движках: тег по `requestIdleCallback`, clickmap / trackLinks / accurateTrackBounce, `<noscript>`.
|
|
8
|
+
* Клиентские переходы старые движки не знали (там каждая страница — новый документ), здесь их считает `hit`.
|
|
9
|
+
*/
|
|
10
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
11
|
+
const id = Number(useRuntimeConfig().public.metrikaId);
|
|
12
|
+
if (!id) return;
|
|
13
|
+
|
|
14
|
+
useHead({
|
|
15
|
+
link: [
|
|
16
|
+
{ rel: 'preconnect', href: 'https://mc.yandex.ru' },
|
|
17
|
+
{ rel: 'dns-prefetch', href: 'https://mc.yandex.ru' },
|
|
18
|
+
],
|
|
19
|
+
noscript: [{ innerHTML: `<div><img src="https://mc.yandex.ru/watch/${id}" style="position:absolute; left:-9999px;" alt="" /></div>` }],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (import.meta.server) return;
|
|
23
|
+
|
|
24
|
+
const win = window as unknown as { ym?: YmFunction };
|
|
25
|
+
win.ym = win.ym || function (...args: unknown[]) {
|
|
26
|
+
(win.ym!.a = win.ym!.a || []).push(args);
|
|
27
|
+
} as YmFunction;
|
|
28
|
+
|
|
29
|
+
const idle = window.requestIdleCallback || ((callback: () => void) => setTimeout(callback, 2500));
|
|
30
|
+
idle(() => {
|
|
31
|
+
win.ym!.l = Date.now();
|
|
32
|
+
const script = document.createElement('script');
|
|
33
|
+
script.async = true;
|
|
34
|
+
script.src = TAG_URL;
|
|
35
|
+
document.head.appendChild(script);
|
|
36
|
+
win.ym!(id, 'init', { clickmap: true, trackLinks: true, accurateTrackBounce: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let previousUrl = window.location.href;
|
|
40
|
+
nuxtApp.hook('page:finish', () => {
|
|
41
|
+
const url = window.location.href;
|
|
42
|
+
if (url === previousUrl) return;
|
|
43
|
+
|
|
44
|
+
win.ym!(id, 'hit', url, { referer: previousUrl, title: document.title });
|
|
45
|
+
previousUrl = url;
|
|
46
|
+
});
|
|
47
|
+
});
|