AI 生成: 一个基于 Python Flask 的影视采集站,自动从公开采集接口抓取影视资源,提供现代风格的分类浏览页面和在线播放功能。
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Movie Collector Site
|
||||
|
||||
一个基于 Python Flask 的影视自动采集站,提供现代风格的分类浏览页面与在线播放功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 自动从公开影视采集接口(苹果CMS格式)拉取影视数据
|
||||
- 现代响应式 UI,支持分类筛选、搜索
|
||||
- 点击影片进入详情页,支持多线路在线播放(HLS/m3u8)
|
||||
- 本地 JSON 缓存,减少对采集源的请求
|
||||
- 无需数据库,开箱即用
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
movie-collector-site/
|
||||
├── app.py # Flask 主程序
|
||||
├── collector.py # 采集模块
|
||||
├── requirements.txt # 依赖
|
||||
├── README.md
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html
|
||||
│ ├── category.html
|
||||
│ └── detail.html
|
||||
└── static/
|
||||
├── css/style.css
|
||||
└── js/main.js
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
浏览器访问 http://127.0.0.1:5000
|
||||
|
||||
## 采集源配置
|
||||
|
||||
编辑 `collector.py` 中的 `API_BASE` 变量,替换为可用的苹果CMS采集接口地址。
|
||||
|
||||
## 播放说明
|
||||
|
||||
详情页使用 hls.js 播放 m3u8 流,需浏览器支持 MSE。
|
||||
@@ -0,0 +1,48 @@
|
||||
from flask import Flask, render_template, request, abort
|
||||
import collector
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@app.route("/category/<int:cat_id>")
|
||||
def index(cat_id=None):
|
||||
keyword = request.args.get("wd", "").strip()
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
categories = collector.get_categories_cached()
|
||||
try:
|
||||
movies = collector.fetch_list(category_id=cat_id, page=page, keyword=keyword or None)
|
||||
except Exception as e:
|
||||
print(f"[app] 采集异常: {e}")
|
||||
movies = []
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
categories=categories,
|
||||
movies=movies,
|
||||
current_cat=cat_id,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/detail/<int:vod_id>")
|
||||
def detail(vod_id):
|
||||
try:
|
||||
movie = collector.fetch_detail(vod_id)
|
||||
except Exception as e:
|
||||
print(f"[app] 详情采集异常: {e}")
|
||||
movie = None
|
||||
if not movie:
|
||||
abort(404)
|
||||
return render_template("detail.html", movie=movie, categories=collector.get_categories_cached())
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template("404.html") if False else ("页面不存在", 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
|
||||
# 苹果CMS 采集接口地址(可替换为任意可用源)
|
||||
API_BASE = "https://caiji.dyttzyapi.com/api.php/provide/vod"
|
||||
|
||||
# 本地缓存文件
|
||||
CACHE_FILE = "cache.json"
|
||||
CACHE_TTL = 60 * 30 # 30 分钟
|
||||
|
||||
|
||||
def _fetch(params):
|
||||
"""调用采集接口"""
|
||||
try:
|
||||
resp = requests.get(API_BASE, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[collector] 请求失败: {e}")
|
||||
return {"list": []}
|
||||
|
||||
|
||||
def fetch_categories():
|
||||
"""获取分类列表"""
|
||||
data = _fetch({"ac": "list"})
|
||||
cats = data.get("class", []) or []
|
||||
result = []
|
||||
for c in cats:
|
||||
result.append({"id": c.get("type_id"), "name": c.get("type_name")})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_list(category_id=None, page=1, keyword=None):
|
||||
"""获取影视列表"""
|
||||
params = {"ac": "detail", "pg": page}
|
||||
if category_id:
|
||||
params["t"] = category_id
|
||||
if keyword:
|
||||
params["wd"] = keyword
|
||||
data = _fetch(params)
|
||||
return _normalize_list(data.get("list", []) or [])
|
||||
|
||||
|
||||
def fetch_detail(vod_id):
|
||||
"""获取影视详情"""
|
||||
data = _fetch({"ac": "detail", "ids": vod_id})
|
||||
items = data.get("list", []) or []
|
||||
if not items:
|
||||
return None
|
||||
return _normalize_detail(items[0])
|
||||
|
||||
|
||||
def _normalize_list(items):
|
||||
out = []
|
||||
for it in items:
|
||||
out.append({
|
||||
"vod_id": it.get("vod_id"),
|
||||
"name": it.get("vod_name"),
|
||||
"pic": it.get("vod_pic"),
|
||||
"remarks": it.get("vod_remarks"),
|
||||
"year": it.get("vod_year"),
|
||||
"area": it.get("vod_area"),
|
||||
"type_name": it.get("type_name"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_detail(it):
|
||||
play_from = (it.get("vod_play_from") or "").split("$$$")
|
||||
play_url = (it.get("vod_play_url") or "").split("$$$")
|
||||
episodes = []
|
||||
for i, urls in enumerate(play_url):
|
||||
source = play_from[i] if i < len(play_from) else f"线路{i+1}"
|
||||
ep_list = []
|
||||
for seg in urls.split("#"):
|
||||
if "$" in seg:
|
||||
title, url = seg.split("$", 1)
|
||||
ep_list.append({"title": title, "url": url})
|
||||
episodes.append({"source": source, "episodes": ep_list})
|
||||
|
||||
return {
|
||||
"vod_id": it.get("vod_id"),
|
||||
"name": it.get("vod_name"),
|
||||
"pic": it.get("vod_pic"),
|
||||
"content": it.get("vod_content"),
|
||||
"year": it.get("vod_year"),
|
||||
"area": it.get("vod_area"),
|
||||
"type_name": it.get("type_name"),
|
||||
"remarks": it.get("vod_remarks"),
|
||||
"actors": it.get("vod_actor"),
|
||||
"director": it.get("vod_director"),
|
||||
"play_sources": episodes,
|
||||
}
|
||||
|
||||
|
||||
# ------- 本地缓存 -------
|
||||
|
||||
def _load_cache():
|
||||
if not os.path.exists(CACHE_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(data):
|
||||
try:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"[collector] 缓存写入失败: {e}")
|
||||
|
||||
|
||||
def get_categories_cached():
|
||||
cache = _load_cache()
|
||||
now = time.time()
|
||||
if "categories" in cache and now - cache["categories"].get("ts", 0) < CACHE_TTL:
|
||||
return cache["categories"]["data"]
|
||||
data = fetch_categories()
|
||||
cache["categories"] = {"ts": now, "data": data}
|
||||
_save_cache(cache)
|
||||
return data
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.3
|
||||
requests==2.32.3
|
||||
@@ -0,0 +1,135 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #0f1115;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1a1d24, #232733);
|
||||
border-bottom: 1px solid #2a2f3a;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.header-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; gap: 16px;
|
||||
}
|
||||
.logo { font-size: 20px; font-weight: 700; color: #fff; white-space: nowrap; }
|
||||
.search { display: flex; flex: 1; max-width: 420px; }
|
||||
.search input {
|
||||
flex: 1; padding: 8px 12px; border-radius: 8px 0 0 8px;
|
||||
border: 1px solid #333a47; background: #14171d; color: #eee; outline: none;
|
||||
}
|
||||
.search button {
|
||||
padding: 8px 16px; border: none; border-radius: 0 8px 8px 0;
|
||||
background: #4f8cff; color: #fff; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.search button:hover { background: #3a78ef; }
|
||||
|
||||
/* Nav */
|
||||
.nav { background: #14171d; border-bottom: 1px solid #232733; overflow-x: auto; }
|
||||
.nav-inner { display: flex; gap: 6px; padding: 10px 16px; white-space: nowrap; }
|
||||
.nav-inner a {
|
||||
padding: 6px 14px; border-radius: 999px; font-size: 14px;
|
||||
color: #a9b1c1; transition: all .2s;
|
||||
}
|
||||
.nav-inner a:hover { background: #232733; color: #fff; }
|
||||
.nav-inner a.active { background: #4f8cff; color: #fff; }
|
||||
|
||||
/* Main */
|
||||
.main { padding: 24px 16px 60px; }
|
||||
.section-title { font-size: 18px; margin-bottom: 16px; }
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: #181c23; border-radius: 10px; overflow: hidden;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
display: block;
|
||||
}
|
||||
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(79,140,255,.25); }
|
||||
.poster { position: relative; aspect-ratio: 3/4.2; overflow: hidden; background: #232733; }
|
||||
.poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.badge {
|
||||
position: absolute; right: 6px; top: 6px;
|
||||
background: rgba(0,0,0,.7); color: #fff;
|
||||
font-size: 11px; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.info { padding: 8px 10px 12px; }
|
||||
.name {
|
||||
font-size: 14px; font-weight: 600; color: #fff;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.meta { font-size: 12px; color: #8b94a6; margin-top: 2px; }
|
||||
|
||||
/* Pager */
|
||||
.pager {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
gap: 12px; margin-top: 30px;
|
||||
}
|
||||
.pager a, .pager span {
|
||||
padding: 6px 14px; border-radius: 6px; background: #232733;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pager a:hover { background: #4f8cff; color: #fff; }
|
||||
|
||||
.empty { text-align: center; color: #8b94a6; padding: 60px 0; }
|
||||
|
||||
/* Detail */
|
||||
.detail-head { display: flex; gap: 24px; margin-bottom: 30px; flex-wrap: wrap; }
|
||||
.detail-poster {
|
||||
width: 220px; border-radius: 10px; flex-shrink: 0;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.detail-meta { flex: 1; min-width: 260px; }
|
||||
.detail-meta h1 { font-size: 24px; margin-bottom: 10px; color: #fff; }
|
||||
.tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.tags span {
|
||||
font-size: 12px; background: #232733; padding: 2px 10px;
|
||||
border-radius: 999px; color: #a9b1c1;
|
||||
}
|
||||
.detail-meta p { margin-bottom: 6px; color: #c4cbd8; font-size: 14px; }
|
||||
.intro { margin-top: 10px; color: #9aa3b3 !important; line-height: 1.7; }
|
||||
|
||||
/* Player */
|
||||
.player-wrap { background: #14171d; border-radius: 12px; padding: 16px; }
|
||||
.source-tabs { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.source-tab {
|
||||
padding: 6px 14px; border-radius: 8px; border: 1px solid #2a2f3a;
|
||||
background: #1c2027; color: #a9b1c1; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.source-tab.active { background: #4f8cff; color: #fff; border-color: #4f8cff; }
|
||||
.player { width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; overflow: hidden; }
|
||||
.player video { width: 100%; height: 100%; display: block; background: #000; }
|
||||
|
||||
.episodes { margin-top: 16px; }
|
||||
.episode-group { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.ep-btn {
|
||||
padding: 6px 12px; border-radius: 6px; border: 1px solid #2a2f3a;
|
||||
background: #1c2027; color: #c4cbd8; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.ep-btn:hover { background: #4f8cff; color: #fff; border-color: #4f8cff; }
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid #232733; padding: 20px 0;
|
||||
text-align: center; color: #6b7385; font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { flex-wrap: wrap; }
|
||||
.search { max-width: 100%; order: 3; width: 100%; }
|
||||
.detail-poster { width: 140px; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
(function () {
|
||||
var sources = window.__PLAY_SOURCES__ || [];
|
||||
var video = document.getElementById('video');
|
||||
var hls = null;
|
||||
var currentSource = 0;
|
||||
|
||||
function play(url) {
|
||||
if (!url) return;
|
||||
if (hls) { hls.destroy(); hls = null; }
|
||||
|
||||
if (url.indexOf('.m3u8') !== -1 && window.Hls && window.Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(function () {}); });
|
||||
} else {
|
||||
video.src = url;
|
||||
video.play().catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
// 切换线路
|
||||
var tabs = document.querySelectorAll('.source-tab');
|
||||
var groups = document.querySelectorAll('.episode-group');
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
tabs.forEach(function (t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
var idx = parseInt(tab.getAttribute('data-idx'), 10);
|
||||
currentSource = idx;
|
||||
groups.forEach(function (g) {
|
||||
g.style.display = parseInt(g.getAttribute('data-idx'), 10) === idx ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 点击剧集
|
||||
document.querySelectorAll('.ep-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var url = btn.getAttribute('data-url');
|
||||
play(url);
|
||||
});
|
||||
});
|
||||
|
||||
// 自动播放第一集
|
||||
if (sources.length && sources[0].episodes && sources[0].episodes.length) {
|
||||
play(sources[0].episodes[0].url);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}影视采集站{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container header-inner">
|
||||
<a href="/" class="logo">🎬 影视采集站</a>
|
||||
<form class="search" action="/" method="get">
|
||||
<input type="text" name="wd" placeholder="搜索影片..." value="{{ keyword or '' }}">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="container nav-inner">
|
||||
<a href="/" class="{% if not current_cat %}active{% endif %}">全部</a>
|
||||
{% for c in categories %}
|
||||
<a href="/category/{{ c.id }}" class="{% if current_cat == c.id %}active{% endif %}">{{ c.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
© {{ 2025 }} 影视采集站 · 仅供学习交流
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ movie.name }} - 影视采集站{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="detail">
|
||||
<div class="detail-head">
|
||||
<img class="detail-poster" src="{{ movie.pic }}" alt="{{ movie.name }}" onerror="this.src='https://via.placeholder.com/300x420?text=No+Image'">
|
||||
<div class="detail-meta">
|
||||
<h1>{{ movie.name }}</h1>
|
||||
<p class="tags">
|
||||
{% if movie.year %}<span>{{ movie.year }}</span>{% endif %}
|
||||
{% if movie.area %}<span>{{ movie.area }}</span>{% endif %}
|
||||
{% if movie.type_name %}<span>{{ movie.type_name }}</span>{% endif %}
|
||||
{% if movie.remarks %}<span>{{ movie.remarks }}</span>{% endif %}
|
||||
</p>
|
||||
<p><strong>导演:</strong>{{ movie.director or '未知' }}</p>
|
||||
<p><strong>主演:</strong>{{ movie.actors or '未知' }}</p>
|
||||
<p class="intro">{{ movie.content or '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-wrap">
|
||||
<div class="source-tabs">
|
||||
{% for s in movie.play_sources %}
|
||||
<button class="source-tab {% if loop.first %}active{% endif %}" data-idx="{{ loop.index0 }}">{{ s.source }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="player">
|
||||
<video id="video" controls playsinline></video>
|
||||
</div>
|
||||
|
||||
<div class="episodes">
|
||||
{% for s in movie.play_sources %}
|
||||
<div class="episode-group" data-idx="{{ loop.index0 }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||
{% for ep in s.episodes %}
|
||||
<button class="ep-btn" data-url="{{ ep.url }}">{{ ep.title }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__PLAY_SOURCES__ = {{ movie.play_sources | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}首页 - 影视采集站{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if keyword %}
|
||||
<h2 class="section-title">搜索:{{ keyword }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if movies %}
|
||||
<div class="grid">
|
||||
{% for m in movies %}
|
||||
<a class="card" href="/detail/{{ m.vod_id }}">
|
||||
<div class="poster">
|
||||
<img src="{{ m.pic }}" alt="{{ m.name }}" loading="lazy" onerror="this.src='https://via.placeholder.com/300x420?text=No+Image'">
|
||||
{% if m.remarks %}<span class="badge">{{ m.remarks }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ m.name }}</div>
|
||||
<div class="meta">{{ m.year or '' }} {{ m.type_name or '' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{% if keyword %}&wd={{ keyword }}{% endif %}">上一页</a>
|
||||
{% endif %}
|
||||
<span>第 {{ page }} 页</span>
|
||||
<a href="?page={{ page + 1 }}{% if keyword %}&wd={{ keyword }}{% endif %}">下一页</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无数据,请检查采集源配置。</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user