Telegram 频道展示 项目完整部署指南
技术栈
- 后端: Node.js + Express
- 前端: 原生 HTML/CSS/JavaScript
- 代理: Nginx 反向代理
- API: Telegram Bot API
- 部署: PM2 进程管理
项目结构
text
telegram-blog/
├── package.json # 项目依赖配置
├── server.js # 主服务器文件
├── .env # 环境变量配置
└── public/ # 前端静态文件
├── index.html # 主页面
├── style.css # 样式文件
└── app.js # 前端逻辑
文件配置详解
1. package.json
json
{
"name": "telegram-blog",
"version": "1.0.0",
"description": "Telegram频道内容展示网站",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"node-cron": "^3.0.2",
"axios": "^1.5.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
2. .env 环境变量配置
env
# 服务器配置
PORT=3000
NODE_ENV=production
# Telegram Bot 配置
BOT_TOKEN=你的Bot_Token
CHANNEL_ID=你的频道ID
# 缓存配置
CACHE_DURATION=300000 # 缓存时间(5分钟)
MAX_POSTS=50 # 最大文章数量
重要配置说明:
BOT_TOKEN: 在 Telegram 中通过 @BotFather 创建机器人获取CHANNEL_ID: 将机器人添加为频道管理员后,通过 API 获取(通常是 -100 开头的数字)
3. server.js - 主服务器文件
javascript
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const axios = require('axios');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件配置
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// 缓存机制let cachedPosts = [];
let lastFetchTime = 0;
const CACHE_DURATION = parseInt(process.env.CACHE_DURATION) || 300000;
/**
* 获取 Telegram 频道内容
* 支持两种API方式:getChatHistory 和 getUpdates
*/async function fetchTelegramPosts() {
const BOT_TOKEN = process.env.BOT_TOKEN;
const CHANNEL_ID = process.env.CHANNEL_ID;
if (!BOT_TOKEN || !CHANNEL_ID) {
throw new Error('请配置 BOT_TOKEN 和 CHANNEL_ID 环境变量');
}
try {
console.log('正在从 Telegram 频道获取内容...');
// 方法1: 使用 getChatHistory APItry {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/getChatHistory`;
const response = await axios.get(url, {
params: {
chat_id: CHANNEL_ID,
limit: 20
},
timeout: 10000
});
if (response.data.ok && response.data.result) {
return processBotAPIData(response.data.result);
}
} catch (error) {
console.log('getChatHistory 失败:', error.message);
}
// 方法2: 回退到 getUpdates APItry {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/getUpdates`;
const response = await axios.get(url, {
timeout: 10000
});
if (response.data.ok && response.data.result) {
return processGetUpdatesData(response.data.result);
}
} catch (error) {
console.log('getUpdates 失败:', error.message);
}
throw new Error('所有 API 方法都失败了');
} catch (error) {
console.error('获取 Telegram 内容失败:', error.message);
throw error;
}
}
/**
* 处理 getChatHistory 返回的数据
*/function processBotAPIData(messages) {
const posts = [];
messages.forEach((item) => {
const message = item.message || item.channel_post;
if (!message) return;
const post = {
id: message.message_id,
text: message.text || message.caption || '(无文字内容)',
time: new Date(message.date * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
image: null
};
// 处理图片消息if (message.photo && message.photo.length > 0) {
const photo = message.photo[message.photo.length - 1];
post.image = `https://api.telegram.org/bot${process.env.BOT_TOKEN}/getFile?file_id=${photo.file_id}`;
}
// 处理文档消息if (message.document) {
post.document = message.document.file_name;
}
posts.push(post);
});
return posts.sort((a, b) => b.id - a.id);
}
/**
* 处理 getUpdates 返回的数据
*/function processGetUpdatesData(updates) {
const posts = [];
updates.forEach((update) => {
const message = update.channel_post;
if (!message) return;
const post = {
id: message.message_id,
text: message.text || message.caption || '(无文字内容)',
time: new Date(message.date * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
image: null
};
// 标记图片消息if (message.photo && message.photo.length > 0) {
post.hasImage = true;
post.image = `图片消息 ID: ${message.message_id}`;
}
posts.push(post);
});
return posts.sort((a, b) => b.id - a.id);
}
// API 路由 - 获取文章列表
app.get('/api/posts', async (req, res) => {
try {
const now = Date.now();
// 检查缓存是否过期if (now - lastFetchTime > CACHE_DURATION || cachedPosts.length === 0) {
console.log('缓存过期,重新获取数据...');
cachedPosts = await fetchTelegramPosts();
lastFetchTime = now;
}
res.json({
ok: true,
channel: 'Kadriyeblog',
posts: cachedPosts,
count: cachedPosts.length,
cached: lastFetchTime
});
} catch (error) {
console.error('API 错误:', error.message);
res.status(500).json({
ok: false,
error: error.message,
posts: cachedPosts.length > 0 ? cachedPosts : []
});
}
});
// 手动刷新缓存
app.post('/api/refresh', async (req, res) => {
try {
cachedPosts = await fetchTelegramPosts();
lastFetchTime = Date.now();
res.json({
ok: true,
message: '缓存刷新成功',
postsCount: cachedPosts.length
});
} catch (error) {
res.status(500).json({
ok: false,
error: error.message
});
}
});
// 健康检查接口
app.get('/api/health', (req, res) => {
res.json({
ok: true,
status: 'running',
cachedPosts: cachedPosts.length,
lastFetch: new Date(lastFetchTime).toISOString(),
environment: process.env.NODE_ENV
});
});
// 提供前端页面
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// 启动服务器
app.listen(PORT, () => {
console.log(`🚀 Telegram Blog 服务器已启动`);
console.log(`📍 访问地址: <http://localhost>:${PORT}`);
console.log(`📱 频道: Kadriyeblog`);
console.log(`💾 环境: ${process.env.NODE_ENV || 'development'}`);
// 启动时预加载数据fetchTelegramPosts().then(posts => {
cachedPosts = posts;
lastFetchTime = Date.now();
console.log(`✅ 初始数据加载完成,共 ${posts.length} 篇文章`);
}).catch(error => {
console.log('❌ 初始数据加载失败:', error.message);
});
});
// 优雅关闭处理
process.on('SIGINT', () => {
console.log('\\n👋 正在关闭服务器...');
process.exit(0);
});
4. public/index.html - 前端主页面
html
<!DOCTYPE html><html lang="zh-CN" data-theme="dark"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Kadriyeblog - Telegram频道</title><link rel="stylesheet" href="style.css"></head><body><div class="container"><header class="header"><div class="channel-info"><img src="<https://pic1.imgdb.cn/item/68d80e9ec5157e1a883d35de.png>" alt="Kadriyeblog" class="avatar"><div class="channel-meta"><h1>Kadriyeblog</h1><p id="channel-desc">Telegram频道内容聚合</p><div class="stats"><span id="posts-count">加载中...</span><span id="last-update"></span></div></div></div><button id="theme-toggle" class="theme-btn">🌙 深色模式</button></header><div class="content-wrapper"><aside class="sidebar"><div class="toc"><h3>文章目录</h3><ul id="toc-list"></ul></div><div class="actions"><button id="refresh-btn" class="action-btn">🔄 刷新</button><button id="scroll-top" class="action-btn">⬆️ 回顶部</button></div></aside><main class="main-content"><div class="posts-container"><div id="loading" class="loading">正在加载文章...</div><div id="posts-list" class="posts-list"></div></div></main></div></div><script src="app.js"></script></body></html>
5. public/style.css - 样式文件
css
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-card: #363636;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--accent-color: #007acc;
--border-color: #404040;
--success-color: #4CAF50;
--error-color: #f44336;
}
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--accent-color: #007acc;
--border-color: #e0e0e0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: all 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* 头部样式 */.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 30px;
}
.channel-info {
display: flex;
align-items: center;
gap: 15px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid var(--accent-color);
}
.channel-meta h1 {
font-size: 24px;
margin-bottom: 5px;
}
.channel-meta p {
color: var(--text-secondary);
margin-bottom: 8px;
}
.stats {
display: flex;
gap: 15px;
font-size: 14px;
color: var(--text-secondary);
}
.theme-btn {
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 15px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.theme-btn:hover {
background: var(--accent-color);
}
/* 内容布局 */.content-wrapper {
display: grid;
grid-template-columns: 280px 1fr;
gap: 30px;
min-height: 70vh;
}
/* 侧边栏 */.sidebar {
background: var(--bg-secondary);
padding: 20px;
border-radius: 12px;
height: fit-content;
position: sticky;
top: 20px;
}
.toc h3 {
margin-bottom: 15px;
font-size: 16px;
color: var(--text-primary);
}
#toc-list {
list-style: none;
max-height: 400px;
overflow-y: auto;
}
#toc-list li {
margin-bottom: 8px;
}
#toc-list a {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
padding: 5px 0;
display: block;
transition: color 0.3s ease;
}
#toc-list a:hover {
color: var(--accent-color);
}
.actions {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.action-btn {
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.action-btn:hover {
background: var(--accent-color);
}
/* 文章列表 */.posts-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.post-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
}
.post-card:hover {
border-color: var(--accent-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.post-time {
color: var(--text-secondary);
font-size: 14px;
}
.post-id {
background: var(--accent-color);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.post-content {
margin-bottom: 15px;
}
.post-text {
white-space: pre-line;
line-height: 1.6;
}
.post-image {
margin-top: 15px;
}
.post-image img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--border-color);
}
/* 加载状态 */.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
font-size: 16px;
}
.error {
background: var(--error-color);
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.success {
background: var(--success-color);
color: white;
padding: 10px;
border-radius: 6px;
margin-bottom: 15px;
text-align: center;
}
/* 响应式设计 */@media (max-width: 768px) {
.content-wrapper {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
order: 2;
}
.main-content {
order: 1;
}
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.channel-info {
flex-direction: column;
text-align: center;
}
}
6. public/app.js - 前端逻辑
javascript
class TelegramBlog {
constructor() {
this.API_BASE = '/api/posts';
this.init();
}
init() {
this.initTheme();
this.bindEvents();
this.loadPosts();
}
// 初始化主题initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
this.updateThemeButton(savedTheme);
}
// 更新主题按钮updateThemeButton(theme) {
const btn = document.getElementById('theme-toggle');
btn.textContent = theme === 'dark' ? '☀️ 浅色模式' : '🌙 深色模式';
}
// 绑定事件bindEvents() {
// 主题切换
document.getElementById('theme-toggle').addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
this.updateThemeButton(newTheme);
});
// 刷新按钮
document.getElementById('refresh-btn').addEventListener('click', () => {
this.refreshPosts();
});
// 回顶部按钮
document.getElementById('scroll-top').addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// 加载文章async loadPosts() {
const loading = document.getElementById('loading');
const postsList = document.getElementById('posts-list');
try {
loading.style.display = 'block';
postsList.innerHTML = '';
const response = await fetch(this.API_BASE);
const data = await response.json();
if (data.ok) {
this.renderPosts(data.posts);
this.updateStats(data);
this.showMessage('数据加载成功', 'success');
} else {
throw new Error(data.error || '加载失败');
}
} catch (error) {
this.showError(error.message);
} finally {
loading.style.display = 'none';
}
}
// 刷新文章async refreshPosts() {
try {
const response = await fetch('/api/refresh', { method: 'POST' });
const data = await response.json();
if (data.ok) {
this.showMessage('数据刷新成功', 'success');
setTimeout(() => this.loadPosts(), 500);
} else {
throw new Error(data.error || '刷新失败');
}
} catch (error) {
this.showError(error.message);
}
}
// 渲染文章列表renderPosts(posts) {
const postsList = document.getElementById('posts-list');
const tocList = document.getElementById('toc-list');
if (posts.length === 0) {
postsList.innerHTML = '<div class="error">暂无文章内容</div>';
tocList.innerHTML = '<li>暂无目录</li>';
return;
}
// 渲染文章
postsList.innerHTML = posts.map(post => `
<article class="post-card" id="post-${post.id}">
<div class="post-header">
<span class="post-time">${post.time}</span>
<span class="post-id">#${post.id}</span>
</div>
<div class="post-content">
<div class="post-text">${this.escapeHtml(post.text)}</div>
${post.image ? `
<div class="post-image">
<img src="${post.image}" alt="文章图片" onerror="this.style.display='none'">
</div>
` : ''}
</div>
</article>
`).join('');
// 渲染目录
tocList.innerHTML = posts.map(post => `
<li>
<a href="#post-${post.id}" title="${post.text.substring(0, 50)}...">
${post.time.split(' ')[0]} #${post.id}
</a>
</li>
`).join('');
}
// 更新统计信息updateStats(data) {
document.getElementById('posts-count').textContent = `共 ${data.count} 篇文章`;
document.getElementById('last-update').textContent = `最后更新: ${new Date().toLocaleTimeString()}`;
}
// 显示消息showMessage(message, type = 'success') {
const existingMsg = document.querySelector('.message');
if (existingMsg) existingMsg.remove();
const msg = document.createElement('div');
msg.className = `message ${type}`;
msg.textContent = message;
document.querySelector('.main-content').prepend(msg);
setTimeout(() => msg.remove(), 3000);
}
// 显示错误showError(message) {
const postsList = document.getElementById('posts-list');
postsList.innerHTML = `
<div class="error">
<h3>加载失败</h3>
<p>${message}</p>
<button onclick="blog.loadPosts()" class="action-btn" style="margin-top: 10px;">重试</button>
</div>
`;
}
// HTML 转义escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\\n/g, '<br>');
}
}
// 初始化应用const blog = new TelegramBlog();
完整部署步骤
1. 服务器环境准备
bash
# 更新系统sudo apt update && sudo apt upgrade -y
# 安装 Node.js(如果未安装)curl -fsSL <https://deb.nodesource.com/setup_18.x> | sudo -E bash -
sudo apt-get install -y nodejs
# 验证安装node --version
npm --version
2. 项目部署
bash
# 创建项目目录mkdir telegram-blog
cd telegram-blog
# 创建 package.json 文件(复制上面的内容)nano package.json
# 安装依赖npm install
# 创建环境变量文件nano .env
# 创建项目结构mkdir public
# 创建服务器文件nano server.js
# 创建前端文件nano public/index.html
nano public/style.css
nano public/app.js
3. 配置 Nginx 反向代理
bash
# 安装 Nginxsudo apt install nginx -y
# 创建 Nginx 配置文件sudo nano /etc/nginx/sites-available/telegram-blog
配置文件内容:
nginx
server {
listen 80;
server_name your-domain.com;# 替换为你的域名location / {
proxy_pass <http://localhost:3001>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 静态文件缓存location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
启用配置:
bash
# 启用网站配置sudo ln -s /etc/nginx/sites-available/telegram-blog /etc/nginx/sites-enabled/
# 测试配置sudo nginx -t
# 重启 Nginxsudo systemctl restart nginx
4. 使用 PM2 管理进程
bash
# 安装 PM2npm install -g pm2
# 启动应用
pm2 start server.js --name telegram-blog
# 设置开机自启
pm2 startup
pm2 save
# 查看状态
pm2 status
pm2 logs telegram-blog
5. 防火墙配置
bash
# 启用防火墙sudo ufw enable
# 放行端口sudo ufw allow 80
sudo ufw allow 22
sudo ufw allow 3001
# 检查状态sudo ufw status
功能特性
✅ 自动缓存机制 - 5分钟缓存减少 API 调用
✅ 双主题支持 - 暗黑/浅色模式一键切换
✅ 响应式设计 - 完美支持桌面和移动端
✅ 实时刷新 - 手动刷新获取最新内容
✅ 错误处理 - 完善的错误提示和重试机制
✅ 健康检查 - 内置服务状态监控接口
✅ 性能优化 - 静态资源缓存和压缩
故障排除
常见问题解决
- 端口占用问题
bash
# 检查端口占用sudo lsof -i :3001
# 杀死占用进程sudo kill -9 <PID>
- Nginx 配置错误
bash
# 测试配置sudo nginx -t
# 查看错误日志sudo tail -f /var/log/nginx/error.log
- 应用启动失败
bash
# 检查 PM2 日志
pm2 logs telegram-blog
# 重启应用
pm2 restart telegram-blog
- API 获取失败
- 检查 Bot Token 和 Channel ID 是否正确
- 确认机器人已添加为频道管理员
- 验证频道是否为公开频道
维护命令
bash
# 查看应用状态
pm2 status
# 查看实时日志
pm2 logs telegram-blog
# 重启应用
pm2 restart telegram-blog
# 停止应用
pm2 stop telegram-blog
# 重新加载 Nginxsudo systemctl reload nginx
# 检查服务状态sudo systemctl status nginx