七十二家房客-观看平台v1.2

This commit is contained in:
云上贵猪 2026-03-11 07:32:49 +08:00
parent a20c6ad819
commit 6978074af3
14 changed files with 337 additions and 125 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.DS_Store .DS_Store
.pw-user-data .pw-user-data
node_modules/ node_modules/
dist/ dist/
npm-debug.log* npm-debug.log*

View File

@ -18,29 +18,35 @@
- 前端分页加载,避免一次加载过多图片 - 前端分页加载,避免一次加载过多图片
--- ---
获取的空数据 # 运行&安装
```json ## 一、后端
安装后端依赖
{ ```shell
"updatedAt": "2026-03-10T18:00:00Z", npm i
"name": "七十二家房客",
"items": []
}
``` ```
# 安装 开启后端服务
```shell
node server.js
```
如需更新数据:
```shell
node capture.js
```
## 二、前端
## 1 安装前端依赖 ## 1 安装前端依赖
``` ```
npm install npm i
```
运行前端服务:
```shell
npm run dev
``` ```
安装播放器依赖:
```
npm install axios hls.js
```
--- ---

View File

@ -1,6 +1,6 @@
{ {
"year": 2024, "year": 2024,
"updatedAt": "2026-03-10T20:58:50.560Z", "updatedAt": "2026-03-10T21:35:04.482Z",
"items": [ "items": [
{ {
"id": "7f012566d7827b5046de9f92a4d7e159", "id": "7f012566d7827b5046de9f92a4d7e159",

View File

@ -1,6 +1,6 @@
{ {
"year": 2025, "year": 2025,
"updatedAt": "2026-03-10T21:02:47.178Z", "updatedAt": "2026-03-10T21:34:22.356Z",
"items": [ "items": [
{ {
"id": "b2b4f80b846360647d13e297c029be8d", "id": "b2b4f80b846360647d13e297c029be8d",

View File

@ -1,6 +1,6 @@
{ {
"year": 2026, "year": 2026,
"updatedAt": "2026-03-10T21:02:47.172Z", "updatedAt": "2026-03-10T21:33:31.594Z",
"items": [ "items": [
{ {
"id": "5abbc88ea530b5db6438a4e584e80281", "id": "5abbc88ea530b5db6438a4e584e80281",

View File

@ -1,8 +1,5 @@
{ {
"running": true, "running": false,
"lastMessage": "已采集第 1 页", "lastMessage": "自动采集完成",
"updatedAt": "2026-03-10T21:11:58.820Z", "updatedAt": "2026-03-10T21:35:26.989Z"
"currentPage": "1",
"pageItems": 0,
"addedCount": 0
} }

View File

@ -1 +0,0 @@
express cors axios

View File

@ -13,25 +13,79 @@ app.use(express.json())
ensureDataFiles() ensureDataFiles()
app.get('/api/qishier/all', (_req, res) => { let memoryCache = {
try { updatedAt: null,
const cache = readAllYearsData() name: '七十二家房客',
coverUrl: '',
displayType: 0,
years: [],
items: []
}
res.json({ function toSimpleItem(item) {
return {
id: item.id,
title: item.title,
coverUrl: item.coverUrl,
releasedAt: item.releasedAt,
timeLength: item.timeLength,
videoUrl: item.videoUrl
}
}
function refreshMemoryCache() {
const cache = readAllYearsData()
memoryCache = {
updatedAt: cache.updatedAt, updatedAt: cache.updatedAt,
name: cache.name || '七十二家房客', name: cache.name || '七十二家房客',
coverUrl: cache.coverUrl || '', coverUrl: cache.coverUrl || '',
displayType: cache.displayType || 0, displayType: cache.displayType || 0,
total: cache.items?.length || 0,
years: cache.years || [], years: cache.years || [],
list: cache.items || [] items: (cache.items || []).map(toSimpleItem)
})
} catch (error) {
res.status(500).json({
message: '读取缓存失败',
error: error.message
})
} }
console.log(
'[server] 内存缓存已刷新:',
'total=', memoryCache.items.length,
'years=', memoryCache.years.length
)
}
refreshMemoryCache()
app.get('/api/qishier/all', (_req, res) => {
res.json({
updatedAt: memoryCache.updatedAt,
name: memoryCache.name,
coverUrl: memoryCache.coverUrl,
displayType: memoryCache.displayType,
total: memoryCache.items.length,
years: memoryCache.years,
list: memoryCache.items
})
})
app.get('/api/qishier/list', (req, res) => {
const page = Math.max(1, Number(req.query.page || 1))
const pageSize = Math.max(1, Math.min(100, Number(req.query.pageSize || 50)))
const total = memoryCache.items.length
const start = (page - 1) * pageSize
const end = start + pageSize
const list = memoryCache.items.slice(start, end)
res.json({
updatedAt: memoryCache.updatedAt,
name: memoryCache.name,
coverUrl: memoryCache.coverUrl,
displayType: memoryCache.displayType,
total,
page,
pageSize,
hasMore: end < total,
years: memoryCache.years,
list
})
}) })
app.get('/api/qishier/year/:year', (req, res) => { app.get('/api/qishier/year/:year', (req, res) => {
@ -46,7 +100,7 @@ app.get('/api/qishier/year/:year', (req, res) => {
displayType: 0, displayType: 0,
total: data.items?.length || 0, total: data.items?.length || 0,
years: [year], years: [year],
list: data.items || [] list: (data.items || []).map(toSimpleItem)
}) })
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@ -56,6 +110,22 @@ app.get('/api/qishier/year/:year', (req, res) => {
} }
}) })
app.post('/api/qishier/refresh', (_req, res) => {
try {
refreshMemoryCache()
res.json({
message: '缓存已刷新',
total: memoryCache.items.length,
updatedAt: memoryCache.updatedAt
})
} catch (error) {
res.status(500).json({
message: '刷新缓存失败',
error: error.message
})
}
})
app.get('/api/qishier/status', (_req, res) => { app.get('/api/qishier/status', (_req, res) => {
try { try {
const status = readStatus() const status = readStatus()
@ -68,6 +138,6 @@ app.get('/api/qishier/status', (_req, res) => {
} }
}) })
app.listen(3000, () => { app.listen(23822, () => {
console.log('Node 服务已启动: http://localhost:3000') console.log('Node 服务已启动: http://127.0.0.1:23822')
}) })

BIN
dist.zip Normal file

Binary file not shown.

View File

@ -15,6 +15,7 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"plyr": "^3.8.4",
"vue": "^3.5.29" "vue": "^3.5.29"
}, },
"devDependencies": { "devDependencies": {

View File

@ -7,28 +7,33 @@ export interface EpisodeData {
releasedAt: number releasedAt: number
timeLength: number timeLength: number
videoUrl: string videoUrl: string
raw?: Record<string, unknown>
} }
export interface QishierCacheResponse { export interface QishierListResponse {
updatedAt: string | null updatedAt: string | null
name: string name: string
coverUrl: string coverUrl: string
displayType: number displayType: number
total: number total: number
beginScoreMap: Record<string, number> years: number[]
pages: Record<string, { currentPage: number; beginScore: number; count: number; capturedAt: string }> page: number
pageSize: number
hasMore: boolean
list: EpisodeData[] list: EpisodeData[]
} }
export function getAllQishierList() { export function getQishierList(page = 1, pageSize = 50) {
return axios.get<QishierCacheResponse>('/api/qishier/all', { return axios.get<QishierListResponse>('/api/qishier/list', {
params: {
page,
pageSize
},
timeout: 15000 timeout: 15000
}) })
} }
export function getQishierStatus() { export function refreshQishierCache() {
return axios.get('/api/qishier/status', { return axios.post('/api/qishier/refresh', {}, {
timeout: 15000 timeout: 15000
}) })
} }

21
src/types/plyr.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
declare module 'plyr' {
export interface PlyrOptions {
controls?: any[]
settings?: any[]
speed?: {
selected?: number
options?: number[]
}
[key: string]: any
}
export default class Plyr {
constructor(target: string | HTMLElement, options?: PlyrOptions)
destroy(): void
play(): Promise<void>
pause(): void
on(event: string, callback: (...args: any[]) => void): void
off(event: string, callback: (...args: any[]) => void): void
source: any
}
}

View File

@ -3,24 +3,21 @@
<div class="header"> <div class="header">
<div> <div>
<h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1> <h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1>
<p> {{ allEpisodeList.length }} 当前显示 {{ visibleEpisodeList.length }} </p> <p> {{ total }} 当前显示 {{ episodeList.length }} </p>
<p class="sub">最后更新{{ updatedAtText }}</p> <p class="sub">最后更新{{ updatedAtText }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="action-btn" @click="fetchList">刷新数据</button> <button class="action-btn" @click="handleRefresh">刷新数据</button>
<button class="action-btn source-btn" @click="openSource">
数据来源荔枝网
</button>
</div> </div>
</div> </div>
<div class="layout"> <div class="layout">
<div class="player-panel"> <div class="player-panel">
<video <video ref="videoRef" class="video" playsinline webkit-playsinline></video>
ref="videoRef"
class="video"
controls
playsinline
webkit-playsinline
></video>
<div v-if="currentEpisode" class="info"> <div v-if="currentEpisode" class="info">
<h2>{{ currentEpisode.title }}</h2> <h2>{{ currentEpisode.title }}</h2>
@ -37,7 +34,7 @@
<div class="list-panel"> <div class="list-panel">
<div <div
v-for="item in visibleEpisodeList" v-for="item in episodeList"
:key="item.id" :key="item.id"
class="episode-item" class="episode-item"
:class="{ active: currentEpisode?.id === item.id }" :class="{ active: currentEpisode?.id === item.id }"
@ -50,9 +47,9 @@
</div> </div>
</div> </div>
<div class="load-more-wrap" v-if="visibleEpisodeList.length < allEpisodeList.length"> <div class="load-more-wrap" v-if="hasMore">
<button class="load-more-btn" @click="loadMoreEpisodes"> <button class="load-more-btn" :disabled="loadingMore" @click="loadMoreEpisodes">
加载更多 {{ loadingMore ? '加载中...' : '加载更多' }}
</button> </button>
</div> </div>
</div> </div>
@ -63,30 +60,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import Hls from 'hls.js' import Hls from 'hls.js'
import { getAllQishierList, type EpisodeData } from '@/api/qishier' import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { getQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier'
const videoRef = ref<HTMLVideoElement | null>(null) const videoRef = ref<HTMLVideoElement | null>(null)
const hlsRef = ref<Hls | null>(null)
const columnInfo = reactive({ const columnInfo = reactive({
name: '', name: '',
coverUrl: '' coverUrl: ''
}) })
const allEpisodeList = ref<EpisodeData[]>([]) const episodeList = ref<EpisodeData[]>([])
const visibleEpisodeList = ref<EpisodeData[]>([])
const currentEpisode = ref<EpisodeData | null>(null) const currentEpisode = ref<EpisodeData | null>(null)
const errorMsg = ref('') const errorMsg = ref('')
const updatedAt = ref<string | null>(null) const updatedAt = ref<string | null>(null)
const pageSize = 20 const total = ref(0)
const currentVisibleCount = ref(pageSize) const currentPage = ref(1)
const pageSize = ref(10)
const hasMore = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
let hlsInstance: Hls | null = null
let plyrInstance: Plyr | null = null
const updatedAtText = computed(() => { const updatedAtText = computed(() => {
if (!updatedAt.value) return '暂无缓存' if (!updatedAt.value) return '暂无缓存'
return new Date(updatedAt.value).toLocaleString('zh-CN') return new Date(updatedAt.value).toLocaleString('zh-CN')
}) })
function openSource() {
window.open('https://www1.gdtv.cn/', '_blank')
}
function setCookie(name: string, value: string, days = 30) { function setCookie(name: string, value: string, days = 30) {
const expires = new Date() const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
@ -105,24 +113,54 @@ function getCookie(name: string): string | null {
return null return null
} }
function updateVisibleList() { function initPlyr() {
visibleEpisodeList.value = allEpisodeList.value.slice(0, currentVisibleCount.value) if (!videoRef.value) return
if (plyrInstance) {
plyrInstance.destroy()
plyrInstance = null
}
plyrInstance = new Plyr(videoRef.value, {
controls: [
'play-large',
'restart',
'rewind',
'play',
'fast-forward',
'progress',
'current-time',
'duration',
'mute',
'volume',
'settings',
'pip',
'airplay',
'fullscreen'
],
settings: ['speed'],
speed: {
selected: 1,
options: [0.5, 0.75, 1, 1.25, 1.5, 2]
}
})
} }
function loadMoreEpisodes() { function destroyPlayer() {
currentVisibleCount.value += pageSize if (hlsInstance) {
updateVisibleList() hlsInstance.destroy()
} hlsInstance = null
}
function destroyHls(): void { if (plyrInstance) {
if (hlsRef.value) { plyrInstance.destroy()
hlsRef.value.destroy() plyrInstance = null
hlsRef.value = null
} }
if (videoRef.value) { if (videoRef.value) {
videoRef.value.ontimeupdate = null videoRef.value.ontimeupdate = null
videoRef.value.onloadedmetadata = null videoRef.value.onloadedmetadata = null
videoRef.value.src = ''
} }
} }
@ -135,51 +173,82 @@ function bindProgressSave(video: HTMLVideoElement, item: EpisodeData) {
function restoreProgress(video: HTMLVideoElement, item: EpisodeData) { function restoreProgress(video: HTMLVideoElement, item: EpisodeData) {
const savedTime = getCookie(`qishier_progress_${item.id}`) const savedTime = getCookie(`qishier_progress_${item.id}`)
if (savedTime && !Number.isNaN(Number(savedTime))) { if (savedTime && !Number.isNaN(Number(savedTime))) {
video.currentTime = Number(savedTime) const time = Number(savedTime)
if (time >= 0) {
video.currentTime = time
}
} }
} }
async function fetchList(): Promise<void> { async function fetchFirstPage(): Promise<void> {
try { try {
loading.value = true
errorMsg.value = '' errorMsg.value = ''
const res = await getAllQishierList()
const data = res.data
console.log('前端拿到缓存:', { const res = await getQishierList(1, pageSize.value)
updatedAt: data.updatedAt, const data = res.data
total: data.total,
pages: Object.keys(data.pages || {}).length,
listLength: Array.isArray(data.list) ? data.list.length : 0
})
columnInfo.name = data.name || '七十二家房客' columnInfo.name = data.name || '七十二家房客'
columnInfo.coverUrl = data.coverUrl || '' columnInfo.coverUrl = data.coverUrl || ''
updatedAt.value = data.updatedAt || null updatedAt.value = data.updatedAt || null
total.value = data.total || 0
currentPage.value = data.page || 1
hasMore.value = !!data.hasMore
episodeList.value = Array.isArray(data.list) ? data.list : []
allEpisodeList.value = Array.isArray(data.list) ? data.list : [] if (episodeList.value.length > 0) {
currentVisibleCount.value = pageSize
updateVisibleList()
if (allEpisodeList.value.length > 0) {
const savedEpisodeId = getCookie('qishier_current_episode_id') const savedEpisodeId = getCookie('qishier_current_episode_id')
const savedEpisode = allEpisodeList.value.find((item) => item.id === savedEpisodeId) const savedEpisode = episodeList.value.find((item) => item.id === savedEpisodeId)
const firstEpisode = episodeList.value[0]
if (savedEpisode) { if (savedEpisode) {
const index = allEpisodeList.value.findIndex((item) => item.id === savedEpisode.id)
if (index >= 0 && index + 1 > currentVisibleCount.value) {
currentVisibleCount.value = Math.ceil((index + 1) / pageSize) * pageSize
updateVisibleList()
}
await playEpisode(savedEpisode) await playEpisode(savedEpisode)
} else { } else if (firstEpisode) {
await playEpisode(allEpisodeList.value[0]) await playEpisode(firstEpisode)
} }
} else { } else {
errorMsg.value = '缓存里还没有节目数据,请先运行采集脚本' errorMsg.value = '暂无节目数据'
} }
} catch (error: any) { } catch (error: any) {
console.error('fetchList error:', error) console.error('fetchFirstPage error:', error)
errorMsg.value = error?.message || '读取缓存失败' errorMsg.value = error?.message || '读取数据失败'
} finally {
loading.value = false
}
}
async function loadMoreEpisodes(): Promise<void> {
if (!hasMore.value || loadingMore.value) return
try {
loadingMore.value = true
const nextPage = currentPage.value + 1
const res = await getQishierList(nextPage, pageSize.value)
const data = res.data
const oldIds = new Set(episodeList.value.map((item) => item.id))
const newItems = (data.list || []).filter((item) => !oldIds.has(item.id))
episodeList.value.push(...newItems)
currentPage.value = data.page || nextPage
hasMore.value = !!data.hasMore
total.value = data.total || total.value
updatedAt.value = data.updatedAt || updatedAt.value
} catch (error: any) {
console.error('loadMoreEpisodes error:', error)
errorMsg.value = error?.message || '加载更多失败'
} finally {
loadingMore.value = false
}
}
async function handleRefresh(): Promise<void> {
try {
await refreshQishierCache()
await fetchFirstPage()
} catch (error: any) {
console.error('handleRefresh error:', error)
errorMsg.value = error?.message || '刷新失败'
} }
} }
@ -193,7 +262,8 @@ async function playEpisode(item: EpisodeData): Promise<void> {
const video = videoRef.value const video = videoRef.value
if (!video) return if (!video) return
destroyHls() destroyPlayer()
initPlyr()
if (video.canPlayType('application/vnd.apple.mpegurl')) { if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = item.videoUrl video.src = item.videoUrl
@ -201,29 +271,27 @@ async function playEpisode(item: EpisodeData): Promise<void> {
video.onloadedmetadata = () => { video.onloadedmetadata = () => {
restoreProgress(video, item) restoreProgress(video, item)
}
bindProgressSave(video, item) bindProgressSave(video, item)
void video.play().catch(() => {}) void video.play().catch(() => {})
}
return return
} }
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls() hlsInstance = new Hls()
hls.loadSource(item.videoUrl) hlsInstance.loadSource(item.videoUrl)
hls.attachMedia(video) hlsInstance.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => { hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
restoreProgress(video, item) restoreProgress(video, item)
bindProgressSave(video, item) bindProgressSave(video, item)
void video.play().catch(() => {}) void video.play().catch(() => {})
}) })
hls.on(Hls.Events.ERROR, () => { hlsInstance.on(Hls.Events.ERROR, () => {
errorMsg.value = '视频播放失败' errorMsg.value = '视频播放失败'
}) })
hlsRef.value = hls
} else { } else {
errorMsg.value = '当前浏览器不支持 m3u8 播放' errorMsg.value = '当前浏览器不支持 m3u8 播放'
} }
@ -246,11 +314,11 @@ function formatDuration(seconds: number): string {
} }
onMounted(() => { onMounted(() => {
void fetchList() void fetchFirstPage()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
destroyHls() destroyPlayer()
}) })
</script> </script>
@ -286,6 +354,7 @@ onBeforeUnmount(() => {
.header-actions { .header-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap;
} }
.action-btn { .action-btn {
@ -295,6 +364,7 @@ onBeforeUnmount(() => {
background: #333; background: #333;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
text-decoration: none;
} }
.action-btn:hover { .action-btn:hover {
@ -409,6 +479,15 @@ onBeforeUnmount(() => {
background: #444; background: #444;
} }
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
:deep(.plyr) {
border-radius: 12px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -2,22 +2,55 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig(({ command }) => ({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
} }
}, },
server: { server: {
port: 5173, port: 23811,
host: true, host: true,
open: true, open: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://127.0.0.1:23822/',
//target: 'http://8.134.120.132:23822/',
changeOrigin: true changeOrigin: true
} }
}
}, },
},
}) // 打包配置
build: {
// https://vite.dev/config/build-options.html
sourcemap: command === 'build' ? false : 'inline',
target: 'es2018',
outDir: 'dist',
assetsDir: 'assets',
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
// JS 拆分
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
// 资源文件
assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
// 手动拆包
manualChunks: {
vue: ['vue'],
hls: ['hls.js']
}
}
}
}
}))