itube-specs 0.0.1051 → 0.0.1053
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 +48 -0
- package/app.config.ts +6 -0
- package/components/grid/videos.vue +13 -0
- package/components/grid/webcams.vue +16 -4
- package/components/player/core.vue +1 -0
- package/components/ui/pagination.vue +5 -0
- package/components/view/model-videos.vue +2 -4
- package/components/view/video.vue +20 -5
- package/components/view/videos.vue +2 -4
- 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 +15 -3
- package/composables/page/use-channel-page.ts +8 -3
- package/composables/page/use-model-page.ts +8 -3
- 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 +2 -0
- package/package.json +1 -1
- package/pages/categories/[slug].vue +2 -4
- package/pages/channels/[channel].vue +2 -4
- package/pages/playlists/[playlistId]/[slug].vue +8 -0
- package/pages/search/[slug]/index.vue +4 -4
- 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/types/player-site-hooks.d.ts +2 -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,54 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1053 — 2026-10-26
|
|
15
|
+
|
|
16
|
+
- **Всем — к сведению:** нативка в конце сеток листингов и похожих (`AdvNativeBottom` в `view/video`, `view/videos`,
|
|
17
|
+
`view/model-videos`, страницах категории, канала, поиска) больше не ленивая. Контейнер приезжает в серверной
|
|
18
|
+
разметке, и сеть заполняет его до гидрации, а ленивая гидрация пересобирала уже заполненный iframe — креатив
|
|
19
|
+
терялся, слот оставался пустым (найдено на hdsex-remaster). Делать ничего не надо.
|
|
20
|
+
- **Сайтам со своими сортировками на страницах на page-composables:** у `useCategoryPage`, `useChannelPage`,
|
|
21
|
+
`useModelPage` новая опция `sortOptions` (по умолчанию `sortItemsDefault`) — по ней ключи меты и проверка `?sort`.
|
|
22
|
+
Без неё свои значения `?sort` такой страницы — 404. Образец — коммит hdsex-remaster с этим изменением.
|
|
23
|
+
- **Сайтам с частотным капом преролла (hdsex-remaster):** в `usePlayerSiteHooks()` новый хук `onAdStart()` — старт
|
|
24
|
+
рекламы в плеере (например, поставить куку капа). Остальным — ничего.
|
|
25
|
+
- **Сайтам с боковой колонкой моделей у плеера (hdsex-remaster):** `appConfig.videoPage.hideAsideModelChips: true` —
|
|
26
|
+
с 1024px не дублировать чипсой над плеером модель, у которой есть блок в колонке. По умолчанию `false`, ничего не
|
|
27
|
+
меняется.
|
|
28
|
+
|
|
29
|
+
## 0.0.1052 — 2026-10-22
|
|
30
|
+
|
|
31
|
+
- **Всем — к сведению:** мусорные адреса листингов теперь 404. `?page` не натуральное число (`0`, `-1`, `abc`, `01`) —
|
|
32
|
+
404 в `normalize.global.ts`; `?sort`, которого нет у страницы, — 404 (`useSortGuard`, его зовёт `useMeta`, у страницы
|
|
33
|
+
категории без `metaKeys` — `useCategoryPage`); `?sort` у страницы без сортировок — тоже 404; модели за последней
|
|
34
|
+
страницей (API отвечает ошибкой) — 404; пустая пагинация за последней страницей (`UiPagination` с `total = 0`) —
|
|
35
|
+
404. Значение `?sort`, совпадающее с ключом API сортировки страницы (`-created`, `rank`, `-updated`), — 301 на неё
|
|
36
|
+
(сортировка по умолчанию — без `sort`). Своя страница со своими сортировками — передавать их в `useMeta`
|
|
37
|
+
(`sortOptions`), иначе её `?sort` станет 404.
|
|
38
|
+
- **Сайтам, переехавшим со старого движка (videosection-redesign, hdsex-remaster, gay-cool-new):** новый ключ
|
|
39
|
+
`appConfig.legacySorts` (через `config/site.config.ts`) — старые значения `?sort`, которых нет у страниц или они под
|
|
40
|
+
другим ключом: старое → ключ API (`''` — аналога нет, 301 на страницу без сортировки). Без карты такие адреса из
|
|
41
|
+
индекса (`?sort=-views`, `?sort=-videos_count`…) станут 404. Образец — коммит videosection-redesign с этим изменением.
|
|
42
|
+
- **Сайтам с поиском по нишам (videosection-redesign, hdsex-remaster):** фича-флаг `FeatureSearchNicheRedirectEnabled`
|
|
43
|
+
теперь читает слой: поиск во «чужой» нише (`/search/gay-anal` на straight) — 302 в нишу фразы; нишу и очищенную фразу
|
|
44
|
+
отдаёт v2 поиска (`POST /bff/search/niche`), как site-flag `isSearchNicheRedirectEnabled` старого движка. Страницы
|
|
45
|
+
поиска слоя зовут `await useSearchNicheRedirect()` первым; своя страница поиска — добавить тот же вызов до запросов
|
|
46
|
+
выдачи. hdsex-remaster: свою реализацию (`server/api/search/niche.get.ts`, `redirectToNicheWithResults` в
|
|
47
|
+
`pages/search/[slug]/index.vue`) удалить — иначе сработают обе.
|
|
48
|
+
- **Сайтам со своей статистикой (hdsex-remaster):** статистика старого движка теперь в слое под `FeatureStatsEnabled`:
|
|
49
|
+
`plugins/stats.client.ts`, `utils/stats/*`, BFF `/bff/stats/{rotator,user,webcams}`, `useDeviceId`, `useWebcamStats`
|
|
50
|
+
(его зовут `GridWebcams` и врезки `GridVideos` — клики/показы вебкам-тумб уходят без вызова на сайте), `useStatsPage`
|
|
51
|
+
(контекст страницы: видео, ролик плейлиста, категория, тег, поиск зовут его сами — ротатор на видео и категории,
|
|
52
|
+
u-logger там же, где на бою). Тумбы сетки видео получили `data-md5` / `data-thumb-number` / `data-pos-number`.
|
|
53
|
+
hdsex-remaster: удалить свои `plugins/stats.client.ts`, `utils/stats-{flush,service,rotator,ulogger,webcams}.ts`,
|
|
54
|
+
`utils/device-id.ts`, `lib/page-info.ts`, `lib/rotator-grid.ts`, `composables/use-webcam-stats.ts`, ручки
|
|
55
|
+
`server/api/stats/{rotator,user,webcams}.post.ts` и вызовы `useWebcamStats` в своих view — иначе при включённом флаге
|
|
56
|
+
статистика уйдёт дважды. Телеметрию рекламы (`stats-ad-availability`, `PUT /stats/c`) оставить своей. Остальным —
|
|
57
|
+
флаг выключен, ничего не меняется.
|
|
58
|
+
- **Сайтам с Яндекс.Метрикой (videosection-redesign; hdsex-remaster — счётчик 94076295 в бэклоге):** счётчик теперь в
|
|
59
|
+
слое — `plugins/metrika.ts`, id из `runtimeConfig.public.metrikaId` (env `NUXT_PUBLIC_METRIKA_ID`; в `nuxt.config.ts`
|
|
60
|
+
сайта — `metrikaId: process.env.NUXT_PUBLIC_METRIKA_ID || ''`), цели — `useMetrikaGoal()`. Пусто — счётчика нет.
|
|
61
|
+
|
|
14
62
|
## 0.0.1045 — 2026-10-20
|
|
15
63
|
|
|
16
64
|
- **Сайтам, у которых бэк включает хинди (`/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
|
/** Состав страницы видео. */
|
|
@@ -35,6 +39,8 @@ export default defineAppConfig({
|
|
|
35
39
|
webcamsBlock: false,
|
|
36
40
|
/** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
|
|
37
41
|
webcamInserts: 8,
|
|
42
|
+
/** С 1024px прятать чипсы моделей над плеером, у которых есть блок в боковой колонке (не дублировать ссылку). */
|
|
43
|
+
hideAsideModelChips: false,
|
|
38
44
|
/** Мета ролика как на старых движках: description — до трёх фраз перехода (недостающие — из тегов), без них —
|
|
39
45
|
* категории, склейка ключами `meta.videos-slug.tags_and` / `tags_tail`; в title доступен `{thirdText}` — название
|
|
40
46
|
* в Title Case. Выключено — description из описания ролика. */
|
|
@@ -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) {
|
|
@@ -38,10 +38,8 @@
|
|
|
38
38
|
priority
|
|
39
39
|
>
|
|
40
40
|
<template #grid-end>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
hydrate-on-visible
|
|
44
|
-
/>
|
|
41
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
42
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
45
43
|
</template>
|
|
46
44
|
</GridVideos>
|
|
47
45
|
<div
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
v-for="(model, index) in data.models"
|
|
30
30
|
:key="index"
|
|
31
31
|
:item="getModelChips(model)"
|
|
32
|
+
:class="{'view-video__model-chips': hasAsideBlock(model.title)}"
|
|
32
33
|
is-link
|
|
33
34
|
/>
|
|
34
35
|
</template>
|
|
@@ -75,11 +76,10 @@
|
|
|
75
76
|
/>
|
|
76
77
|
</template>
|
|
77
78
|
|
|
79
|
+
<!-- Без ленивой гидрации: контейнер приезжает в серверной разметке и сеть заполняет его до гидрации,
|
|
80
|
+
а ленивая гидрация пересобирала уже заполненный iframe — креатив терялся, слот оставался пустым. -->
|
|
78
81
|
<template #grid-end>
|
|
79
|
-
<
|
|
80
|
-
v-if="featureFlags.AdsNativeEnabled"
|
|
81
|
-
hydrate-on-visible
|
|
82
|
-
/>
|
|
82
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
83
83
|
</template>
|
|
84
84
|
</LazyGridVideos>
|
|
85
85
|
</section>
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
|
|
127
127
|
<script setup lang="ts">
|
|
128
128
|
import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
|
|
129
|
-
import { convertModelToRoundCard } from '../../runtime';
|
|
129
|
+
import { convertModelToRoundCard, convertString } from '../../runtime';
|
|
130
130
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
131
131
|
import type { AsyncDataRequestStatus } from '#app';
|
|
132
132
|
|
|
@@ -232,6 +232,15 @@ const topChips = computed(() => {
|
|
|
232
232
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
233
233
|
|
|
234
234
|
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
235
|
+
// `videoPage.hideAsideModelChips`: с 1024px чипса модели не нужна тем, у кого есть блок в боковой колонке плеера
|
|
236
|
+
// (`useFetchAsideModels`). Снимок, а не реактивное чтение: список заполняется эффектом, и реактивный класс
|
|
237
|
+
// пересчитывался бы во время гидрации — разметка расходилась бы с серверной.
|
|
238
|
+
const asideModelSlugs = new Set(videoPage.hideAsideModelChips ? useAsideModelSlugs().value : []);
|
|
239
|
+
|
|
240
|
+
function hasAsideBlock(title: string): boolean {
|
|
241
|
+
return asideModelSlugs.has(convertString().toSlug(title));
|
|
242
|
+
}
|
|
243
|
+
|
|
235
244
|
const placeholderOf = useModelPlaceholder();
|
|
236
245
|
|
|
237
246
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
@@ -283,6 +292,12 @@ function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
|
283
292
|
margin-top: 32px;
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
.view-video__model-chips {
|
|
296
|
+
@include from-br(sd) {
|
|
297
|
+
display: none;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
286
301
|
.view-video__popular-models {
|
|
287
302
|
margin-top: 32px;
|
|
288
303
|
}
|
|
@@ -36,10 +36,8 @@
|
|
|
36
36
|
priority
|
|
37
37
|
>
|
|
38
38
|
<template #grid-end>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
hydrate-on-visible
|
|
42
|
-
/>
|
|
39
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
40
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
43
41
|
</template>
|
|
44
42
|
|
|
45
43
|
<LazySectionSwiperCards
|
|
@@ -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
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CategoriesApiService } from '../../services/api/categories.service';
|
|
2
2
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
3
3
|
import { sortItemsDefault } from '../../lib';
|
|
4
|
-
import type { IBreadcrumbItem, ICardInfo, IChipsItem } from '../../types';
|
|
4
|
+
import type { IBreadcrumbItem, ICardInfo, IChipsItem, INavLinkItem } from '../../types';
|
|
5
5
|
|
|
6
6
|
/** Слаги категорий, у которых не показываем связанные поисковые фразы. */
|
|
7
7
|
const DISALLOWED_SLUGS = ['18', '19'];
|
|
@@ -35,7 +35,11 @@ export async function useCategoryPageData() {
|
|
|
35
35
|
*/
|
|
36
36
|
export function useCategoryPage(
|
|
37
37
|
pageData: Awaited<ReturnType<typeof useCategoryPageData>>,
|
|
38
|
-
options: {
|
|
38
|
+
options: {
|
|
39
|
+
metaKeys?: boolean
|
|
40
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
41
|
+
sortOptions?: INavLinkItem[]
|
|
42
|
+
} = {},
|
|
39
43
|
) {
|
|
40
44
|
const { categoryInfo, data, status, relatedData } = pageData;
|
|
41
45
|
const { t } = useI18n();
|
|
@@ -48,12 +52,20 @@ export function useCategoryPage(
|
|
|
48
52
|
|
|
49
53
|
const title = computed(() => categoryInfo.value?.title || '');
|
|
50
54
|
|
|
55
|
+
// статистика: ротатор листинга категории + u-logger, как на старом движке
|
|
56
|
+
useStatsPage(() => ({ rotatorID: 'category', categories: slug.value ? [slug.value] : [] }));
|
|
57
|
+
|
|
58
|
+
// с metaKeys `?sort` проверяет useMeta
|
|
59
|
+
const sortOptions = options.sortOptions || sortItemsDefault;
|
|
60
|
+
|
|
61
|
+
if (!options.metaKeys) useSortGuard(sortOptions);
|
|
62
|
+
|
|
51
63
|
const keysMeta = options.metaKeys
|
|
52
64
|
? useMeta({
|
|
53
65
|
t,
|
|
54
66
|
pageKey: useGetPureRouteName(),
|
|
55
67
|
brandName: useAppConfig().brandName,
|
|
56
|
-
sortOptions
|
|
68
|
+
sortOptions,
|
|
57
69
|
text: title,
|
|
58
70
|
nicheSafeText: title,
|
|
59
71
|
seo: () => ({
|
|
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
|
|
|
4
4
|
import { sortItemsDefault } from '../../lib';
|
|
5
5
|
import { ChannelsApiService } from '../../services/api/channels.service';
|
|
6
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
7
|
-
import type { IBreadcrumbItem, IChipsItem, IPopularTags } from '../../types';
|
|
7
|
+
import type { IBreadcrumbItem, IChipsItem, IPopularTags, INavLinkItem } from '../../types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Данные страницы канала: карточка канала, видео, популярные теги. Только запросы — их можно запускать в
|
|
@@ -37,7 +37,12 @@ export async function useChannelPageData() {
|
|
|
37
37
|
*/
|
|
38
38
|
export function useChannelPage(
|
|
39
39
|
pageData: Awaited<ReturnType<typeof useChannelPageData>>,
|
|
40
|
-
options: {
|
|
40
|
+
options: {
|
|
41
|
+
metaSort?: MaybeRefOrGetter<string>
|
|
42
|
+
metaSecondText?: Ref<string>
|
|
43
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
44
|
+
sortOptions?: INavLinkItem[]
|
|
45
|
+
} = {},
|
|
41
46
|
) {
|
|
42
47
|
const { cardInfo, status, channelTagsData } = pageData;
|
|
43
48
|
const route = useRoute();
|
|
@@ -50,7 +55,7 @@ export function useChannelPage(
|
|
|
50
55
|
t,
|
|
51
56
|
pageKey: useGetPureRouteName(),
|
|
52
57
|
brandName: useAppConfig().brandName,
|
|
53
|
-
sortOptions: sortItemsDefault,
|
|
58
|
+
sortOptions: options.sortOptions || sortItemsDefault,
|
|
54
59
|
sort: options.metaSort,
|
|
55
60
|
text: metaTitle,
|
|
56
61
|
secondText: options.metaSecondText,
|
|
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
|
|
|
4
4
|
import { sortItemsDefault } from '../../lib';
|
|
5
5
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
6
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
7
|
-
import type { IBreadcrumbItem } from '../../types';
|
|
7
|
+
import type { IBreadcrumbItem, INavLinkItem } from '../../types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Данные страницы модели: видео и карточка модели. Только запросы — их можно запускать в `Promise.all` вместе
|
|
@@ -34,7 +34,12 @@ export async function useModelPageData() {
|
|
|
34
34
|
*/
|
|
35
35
|
export function useModelPage(
|
|
36
36
|
pageData: Awaited<ReturnType<typeof useModelPageData>>,
|
|
37
|
-
options: {
|
|
37
|
+
options: {
|
|
38
|
+
metaSort?: MaybeRefOrGetter<string>
|
|
39
|
+
metaSecondText?: Ref<string>
|
|
40
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
41
|
+
sortOptions?: INavLinkItem[]
|
|
42
|
+
} = {},
|
|
38
43
|
) {
|
|
39
44
|
const { status, cardInfo } = pageData;
|
|
40
45
|
const route = useRoute();
|
|
@@ -48,7 +53,7 @@ export function useModelPage(
|
|
|
48
53
|
t,
|
|
49
54
|
pageKey: useGetPureRouteName(),
|
|
50
55
|
brandName: useAppConfig().brandName,
|
|
51
|
-
sortOptions: sortItemsDefault,
|
|
56
|
+
sortOptions: options.sortOptions || sortItemsDefault,
|
|
52
57
|
sort: options.metaSort,
|
|
53
58
|
text: metaTitle,
|
|
54
59
|
secondText: options.metaSecondText,
|
|
@@ -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
|
+
};
|