commit 5d4679ff1e9cd74b6783843f1608e7475a6a2ec6 Author: admin Date: Mon Sep 28 21:24:13 2026 +0800 AI 生成: 一个基于 Python Flask 的影视采集站,自动从公开采集接口抓取影视资源,提供现代风格的分类浏览页面和在线播放功能。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5375535 --- /dev/null +++ b/README.md @@ -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。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..d66c3ec --- /dev/null +++ b/app.py @@ -0,0 +1,48 @@ +from flask import Flask, render_template, request, abort +import collector + +app = Flask(__name__) + + +@app.route("/") +@app.route("/category/") +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/") +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) diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..794a0e8 --- /dev/null +++ b/collector.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..76d9083 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +requests==2.32.3 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..d10b561 --- /dev/null +++ b/static/css/style.css @@ -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; } +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..a66d4f8 --- /dev/null +++ b/static/js/main.js @@ -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); + } +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f3ca8fd --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}影视采集站{% endblock %} + + + + +
+
+ + +
+
+ + + +
+ {% block content %}{% endblock %} +
+ +
+
+ © {{ 2025 }} 影视采集站 · 仅供学习交流 +
+
+ + diff --git a/templates/detail.html b/templates/detail.html new file mode 100644 index 0000000..da38ffa --- /dev/null +++ b/templates/detail.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% block title %}{{ movie.name }} - 影视采集站{% endblock %} + +{% block content %} +
+
+ {{ movie.name }} +
+

{{ movie.name }}

+

+ {% if movie.year %}{{ movie.year }}{% endif %} + {% if movie.area %}{{ movie.area }}{% endif %} + {% if movie.type_name %}{{ movie.type_name }}{% endif %} + {% if movie.remarks %}{{ movie.remarks }}{% endif %} +

+

导演:{{ movie.director or '未知' }}

+

主演:{{ movie.actors or '未知' }}

+

{{ movie.content or '' }}

+
+
+ +
+
+ {% for s in movie.play_sources %} + + {% endfor %} +
+ +
+ +
+ +
+ {% for s in movie.play_sources %} +
+ {% for ep in s.episodes %} + + {% endfor %} +
+ {% endfor %} +
+
+
+ + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..8109606 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block title %}首页 - 影视采集站{% endblock %} + +{% block content %} + {% if keyword %} +

搜索:{{ keyword }}

+ {% endif %} + + {% if movies %} + + +
+ {% if page > 1 %} + 上一页 + {% endif %} + 第 {{ page }} 页 + 下一页 +
+ {% else %} +
暂无数据,请检查采集源配置。
+ {% endif %} +{% endblock %}