买了一块开发板,鲁班猫a0,H618芯片。安装了Ubuntu当成服务器和NAS用,下面是我的开发过程
连接
两种办法串口连接和ssh。
串口连接
刚买来没网络环境我用的串口连接,官网都有教程。但是我的系统是debian所以官网上的教程有的不适用,我选择的串流软件是minicom。首先插上串口,然后查看串口设备文件
ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null安装配置minicom
# 安装
sudo apt install minicom
# 修改配置
sudo minicom -s按 A 设置串口设备为 /dev/ttyUSB0,按 E 设置波特率115200,按 F 关闭设为 No,选择“保存设置为 dfl”,然后“离开本画面”,就进入lubancat系统内了。
进入系统第一步应该是连接网络并配置静态ip,官方文档配置静态ip顺序有点错误,正确的应该是
连接wifi
sudo nmtui查看网关
route -n设置静态ip
比较长,直接查看官方文档,写的很清楚。https://doc.embedfire.com/linux/h618/quick_start/zh/latest/quick_start/network/network2.html#id13
SSH连接
首先要在同一个局域网内,然后要知道ip地址。 这个直接参照官方文档https://doc.embedfire.com/linux/h618/quick_start/zh/latest/quick_start/login/login.html#ssh
系统配置与服务
网络配置
网络代理
因为是纯命令行所以用的crash
#安装
sudo apt install crash
#运行
crash这样启动有可能权限不够启动失败,所以我换启动脚本
sudo /etc/ShellCrash/menu.sh接下来就和正常配置网络代理一样了,正常导入订阅,如果订阅不能用的话就更换内核试试,别忘设置开机自启,不需要自己设置,脚本里面有快捷配置。
内网穿透
本来打算使用ipv6但是送的路由器太垃圾了不支持打开ipv6的防火墙,然后使用ddnsgo绑定域名,但是还是因为ipv6没办法连到公网,没招了我就选择zerotierhttps://www.zerotier.com/。注意设置的穿透的地址别和实际的ip地址一样。
服务
1panel面板
有一个面板管理起来很方便,而且部署和管理docker看日志也很方便。https://1panel.cn/
casaOS
这个东西没太大用,主要是刚开始不知道要搞什么,算是一个NAS系统吧,用官方文档里的一键安装命令安装。https://doc.embedfire.com/linux/h618/quick_start/zh/latest/lubancat_allwinner_software_hardware/software/CasaOS/casaos.html#id1
Telegram下载机器人
这个是最爽的,telegram里创建一个频道,视频转发进里面就自动下载。需要向botfather申请一个机器人向官方申请开发者api,服务器上运行telegram的服务器端。
pip3 install telethon
pip3 install cryptg下面是ai写的脚本,我的路径在/opt/bot.py。
from telethon import TelegramClient, events, Button
from telethon.tl.types import DocumentAttributeVideo
import asyncio, os, shutil, subprocess, time
from collections import deque
# ================= 核心配置区 =================
api_id =
api_hash = ''
bot_token = ''
proxy = ('http', '127.0.0.1', 7890)
target_chat = -1003744786905
BASE_DIR = '/media/nas/Media'
DIRS = {
"1": {"name": "🎬 电影", "path": f"{BASE_DIR}/Movies"},
"2": {"name": "📺 剧集", "path": f"{BASE_DIR}/TVShows"},
"3": {"name": "📂 默认视频", "path": f"{BASE_DIR}/Video"}
}
# =============================================
client = TelegramClient('/opt/real_bot_session', api_id, api_hash, proxy=proxy)
# 全局状态管理
pending_tasks = {}
active_rename_session = {}
download_queue = asyncio.Queue()
download_history = deque(maxlen=10)
current_download_task = {}
# 工具函数:格式化大小、时长、速度、时间
def format_size(bytes_size):
if bytes_size < 1024**2: return f"{bytes_size/1024:.1f} KB"
elif bytes_size < 1024**3: return f"{bytes_size/(1024**2):.1f} MB"
else: return f"{bytes_size/(1024**3):.2f} GB"
def format_duration(seconds):
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}h {m}m" if h > 0 else f"{m}m {s}s"
def format_speed(bps):
if bps < 1024**2: return f"{bps/1024:.1f} KB/s"
else: return f"{bps/(1024**2):.1f} MB/s"
def format_eta(seconds):
if seconds < 60: return f"{int(seconds)} 秒"
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
if h > 0: return f"{h}小时 {m}分钟"
return f"{m}分钟 {s}秒"
def get_unique_filename(base_path, custom_name, ext):
file_path = os.path.join(base_path, f"{custom_name}{ext}")
counter = 1
while os.path.exists(file_path):
file_path = os.path.join(base_path, f"{custom_name}({counter}){ext}")
counter += 1
return file_path
def get_media_info_text(media):
info_text = "📦 **视频信息**\n\n"
if hasattr(media, 'document'):
size = getattr(media.document, 'size', 0)
info_text += f"💾 大小:`{format_size(size)}`\n"
for attr in media.document.attributes:
if isinstance(attr, DocumentAttributeVideo):
info_text += f"⏱ 时长:`{format_duration(attr.duration)}`\n"
info_text += f"🖥 分辨率:`{attr.w} × {attr.h}`\n"
return info_text
def get_sys_status():
try:
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: temp = int(f.read().strip()) / 1000.0
except: temp = 0.0
try:
with open('/proc/loadavg', 'r') as f: load = f.read().split()[:3]; load_str = f"{load[0]} {load[1]} {load[2]}"
except: load_str = "未知"
mem = subprocess.check_output(['free', '-m']).decode('utf-8').splitlines()[1].split()
s_total, s_used, _ = shutil.disk_usage('/')
try:
t, u, f = shutil.disk_usage(BASE_DIR)
ext_disk = f"已用: `{u/(1024**3):.1f}GB` / 总计: `{t/(1024**3):.1f}GB` ({round(u/t*100,1)}%)"
except: ext_disk = "未检测到硬盘!"
return (f"📊 **鲁班猫 (H618) 状态面板**\n\n"
f"🌡️ **温度:** `{temp:.1f} °C` ⚡ **负载:** `{load_str}`\n"
f"🧠 **内存:** `{mem[2]}MB / {mem[1]}MB`\n"
f"📟 **系统盘:** `{s_used/(1024**3):.1f}GB / {s_total/(1024**3):.1f}GB`\n"
f"💾 **机械硬盘(HDD):**\n {ext_disk}")
# 后台垃圾回收 (GC)
async def task_cleaner():
while True:
now = time.time()
expired = [k for k, v in pending_tasks.items() if now - v['timestamp'] > 1800]
for k in expired: del pending_tasks[k]
await asyncio.sleep(300)
# 单线程下载消费者
async def download_worker():
while True:
task_data = await download_queue.get()
await execute_download(task_data)
download_queue.task_done()
# 1. 常用指令监听 (/history, /queue, /status)
@client.on(events.NewMessage(pattern='/status', chats=target_chat))
async def status_handler(event):
await event.reply(get_sys_status())
@client.on(events.NewMessage(pattern='/history', chats=target_chat))
async def history_handler(event):
if not download_history:
await event.reply("📭 暂无下载记录。")
return
text = "📜 **最近 10 个下载**\n\n"
for i, item in enumerate(reversed(download_history), 1):
text += f"{i}. `{item}`\n"
await event.reply(text)
@client.on(events.NewMessage(pattern='/queue', chats=target_chat))
async def queue_handler(event):
text = "🔄 **当前下载与队列**\n\n"
if current_download_task:
text += f"▶️ **正在下载:**\n`{current_download_task.get('name', '未知')}` [{current_download_task.get('progress', '0%')}]\n\n"
else:
text += "▶️ **正在下载:**\n暂无任务\n\n"
text += "⏳ **等待队列:**\n"
if download_queue.empty():
text += "空闲中"
else:
for i, item in enumerate(download_queue._queue, 1):
name = item.get("final_name_guess") or "未命名视频"
text += f"{i}. `{name}`\n"
await event.reply(text)
# 2. 捕获媒体资源
@client.on(events.NewMessage(chats=target_chat))
async def message_handler(event):
if event.text and event.text.startswith('/'): return
# 核心修复:认频道不认人
if event.chat_id in active_rename_session: return
if event.media:
task_id = event.id
pending_tasks[task_id] = {
"media": event.media,
"msg_obj": event.message,
"timestamp": time.time()
}
info_ui = get_media_info_text(event.media)
await event.reply(
f"{info_ui}\n📥 **请选择保存分类:**",
buttons=[
[Button.inline("🎬 电影", f"dl_1_{task_id}"), Button.inline("📺 剧集", f"dl_2_{task_id}")],
[Button.inline("📂 默认视频", f"dl_3_{task_id}"), Button.inline("❌ 取消任务", f"cancel_{task_id}")]
]
)
# 3. 按钮回调处理
@client.on(events.CallbackQuery())
async def callback_handler(event):
data = event.data.decode()
chat_id = event.chat_id
if data.startswith("cancel_"):
task_id = int(data.split("_")[1])
pending_tasks.pop(task_id, None)
active_rename_session.pop(chat_id, None)
await event.edit("🚫 **任务已取消并清理**。")
return
if data.startswith("dl_"):
_, category_id, task_id_str = data.split("_")
task_id = int(task_id_str)
if task_id not in pending_tasks:
await event.answer("❌ 任务已过期 (超30分钟) 或被清理!", alert=True)
return
cat_name = DIRS[category_id]["name"]
save_path = DIRS[category_id]["path"]
pending_tasks[task_id]["category_id"] = category_id
pending_tasks[task_id]["cat_name"] = cat_name
pending_tasks[task_id]["save_path"] = save_path
# 将任务绑定给当前频道
active_rename_session[chat_id] = task_id
await event.edit(
f"🚀 **已选择:{cat_name}**\n\n"
f"✍️ 请直接发送你要修改的【**文件名**】。\n"
f"💡 *(直接回复数字 `0` 则保持原名)*"
)
# 4. 捕获命名并入队
@client.on(events.NewMessage(chats=target_chat))
async def name_input_handler(event):
chat_id = event.chat_id
if chat_id not in active_rename_session: return
task_id = active_rename_session.pop(chat_id)
task = pending_tasks.pop(task_id, None)
if not task:
await event.reply("❌ 任务异常丢失。")
return
input_name = event.text.strip()
custom_name = "".join([c for c in input_name if c not in r'\/:*?"<>|']) if input_name != "0" else None
task["custom_name"] = custom_name
task["final_name_guess"] = custom_name if custom_name else "默认原名任务"
queue_size = download_queue.qsize()
status_msg = await event.reply(f"⏳ **排队中...**\n前面还有 {queue_size} 个任务在等待。")
task["status_msg"] = status_msg
await download_queue.put(task)
# 5. 带有可视化进度条与 ETA 的核心下载引擎
async def execute_download(task):
save_path = task["save_path"]
cat_name = task["cat_name"]
custom_name = task["custom_name"]
status_msg = task["status_msg"]
os.makedirs(save_path, exist_ok=True)
progress_state = {
"last_time": time.time(),
"last_bytes": 0,
"ui_update_time": 0
}
async def progress_callback(current, total):
now = time.time()
# 控制刷新频率为 3 秒
if now - progress_state["ui_update_time"] > 3:
diff_bytes = current - progress_state["last_bytes"]
diff_time = now - progress_state["last_time"]
speed_bps = diff_bytes / diff_time if diff_time > 0 else 0
# 安全处理总大小,防止死锁
safe_total = total if total else (current + 1)
percent = (current / safe_total) * 100
eta_seconds = (safe_total - current) / speed_bps if speed_bps > 0 else 0
# 渲染进度块
filled = int(percent / 10)
bar = f"[{'█' * filled}{'░' * (10 - filled)}]"
current_download_task["progress"] = f"{percent:.1f}%"
text = (f"⬇️ **下载中... ({cat_name})**\n\n"
f"{bar} `{percent:.1f}%`\n\n"
f"📊 容量: `{format_size(current)} / {format_size(safe_total)}`\n"
f"🚀 速度: `{format_speed(speed_bps)}`\n"
f"⏳ 剩余: `{format_eta(eta_seconds)}`")
try:
await status_msg.edit(text)
except Exception:
pass
progress_state["last_time"] = now
progress_state["last_bytes"] = current
progress_state["ui_update_time"] = now
try:
ext = ".mp4"
if hasattr(task["media"], 'document'):
for attr in task["media"].document.attributes:
if hasattr(attr, 'file_name') and attr.file_name:
ext = os.path.splitext(attr.file_name)[1]
break
if not custom_name:
custom_name = "未命名视频"
if hasattr(task["media"], 'document'):
for attr in task["media"].document.attributes:
if hasattr(attr, 'file_name') and attr.file_name:
custom_name = os.path.splitext(attr.file_name)[0]
break
final_file_path = get_unique_filename(save_path, custom_name, ext)
final_name = os.path.basename(final_file_path)
current_download_task["name"] = final_name
current_download_task["progress"] = "0%"
await status_msg.edit(f"🚀 开始下载:`{final_name}`...")
await client.download_media(task["media"], file=final_file_path, progress_callback=progress_callback)
current_download_task.clear()
download_history.append(final_name)
final_size = os.path.getsize(final_file_path)
finish_text = (f"🎉 **下载完成**\n\n"
f"📁 文件:\n`{final_name}`\n\n"
f"💾 大小:\n`{format_size(final_size)}`\n\n"
f"🏷 分类:\n`{cat_name}`")
await status_msg.edit(finish_text)
except Exception as e:
current_download_task.clear()
await status_msg.edit(f"❌ **下载失败**\n错误: {str(e)}")
print("机器人启动...")
loop = asyncio.get_event_loop()
loop.create_task(task_cleaner())
loop.create_task(download_worker())
# 携带 Bot Token 启动
client.start(bot_token=bot_token)
client.run_until_disconnected()Docker
我的建议是全部在1panel上部署,这样方便查看和管理,而且操作也简单方便。下面是几个好用的:
freellmapi
https://github.com/tashfeenahmed/freellmapi这个是集成全网免费的ai,需要自己一个一个注册输入api,如果用得少不的话随便配置几个就行了,我主要是用来玩一些可以接入ai的游戏,纯娱乐,如果需要智商不高免费的ai的话挺值得搞一个的。下面是在docker部署的步骤:
1. 创建专属工作目录
为了避免权限错乱,在普通用户家目录下创建项目文件夹:
mkdir -p ~/freellmapi && cd ~/freellmapi2. 生成 .env 配置文件
自动生成安全所需的 32 位随机 AES 密钥并写入配置文件:
ENCRYPTION_KEY="$(openssl rand -hex 32)"
printf "ENCRYPTION_KEY=%s\nPORT=3001\nHOST_BIND=0.0.0.0\n" "$ENCRYPTION_KEY" > .env3.配置代理
修改 Docker 核心总控文件 /etc/docker/daemon.json:
sudo mkdir -p /etc/docker
sudo cat << 'EOF' > /etc/docker/daemon.json
{
"proxies": {
"http-proxy": "http://127.0.0.1:7890",
"https-proxy": "http://127.0.0.1:7890",
"no-proxy": "localhost,127.0.0.1"
}
}
EOF重启 Docker 并拉取原生镜像
sudo systemctl restart docker
docker compose pull执行完后恢复 Docker 全局网络
sudo cat << 'EOF' > /etc/docker/daemon.json
{}
EOF
sudo systemctl restart docker4.配置 docker-compose.yml
在 ~/freellmapi 目录下,创建并写入以下配置文件
cat << 'EOF' > docker-compose.yml
services:
freellmapi:
image: ghcr.io/tashfeenahmed/freellmapi:latest
network_mode: "host"
env_file:
- .env
environment:
NODE_ENV: production
PORT: 3001
# 仅本容器内部请求会走 7890 代理,绝对不影响宿主机和其他应用
HTTP_PROXY: http://127.0.0.1:7890
HTTPS_PROXY: http://127.0.0.1:7890
http_proxy: http://127.0.0.1:7890
https_proxy: http://127.0.0.1:7890
PROXY_URL: http://127.0.0.1:7890
volumes:
- freellmapi-data:/app/server/data
restart: unless-stopped
volumes:
freellmapi-data:
EOF5.启动容器
docker compose up -d查看是否成功
docker compose logs --tail=20如果有
[proxy] Configured → http://127.0.0.1:7890
Server running on http://[::]:3001代表成功。
6.启动
IP:3001
FRPC
这个也是内网穿透,但是和zerotier不一样的是这个是连接到我的阿里云服务器,有公网ip,可以直接访问。搞这个的原因是手机上zerotier是vpn服务,如果自己再开一个vpn使用国外工具的话两者不能共存(手机只支持开一个vpn)。直接在1panel的商店找到就行,然后按照模板改下配置文件配置ip地址和密码,在服务端也改一下就行了。路径是/opt/1panel/apps/frpc/.env
Navidrome
创建文件
登录1Panel ,进入文件。直接在 /opt 目录下新建一个名为 navidrome 的文件夹。在 /opt/navidrome 内部,继续创建两个子文件夹:data (用于存放系统数据)music 。点击 1Panel 左侧菜单的 “容器” -> “编排”。点击右上角的 “创建编排”。我安装了casaos,里面下载了音乐所以存储第一个挂载/date第二个挂载真正的音乐文件夹。
编排容器
编排名称:填写 navidrome,编辑模式:选择“文本”。
version: "3"
services:
navidrome:
image: deluan/navidrome:latest
container_name: navidrome
ports:
- "4533:4533"
restart: unless-stopped
environment:
ND_LOGLEVEL: "info"
ND_SESSIONTIMEOUT: "24h"
ND_BASEURL: ""
ND_DEFAULTLANGUAGE: "zh-CN"
volumes:
- "/opt/navidrome/data:/data"
- "/DATA/Media/Music:/music:ro"点击确定即可
设置
进入4533端口,完成初始化设置,创建博客所需的歌单进入歌单复制浏览器后的那一串id。在插件设置里公网ip填http://192.168.1.6:4533(按自己的来),容器/内网服务地址:http://navidrome:4533,其他照填。歌单id填刚刚复制的那串。
改写
感觉原版的图标太丑了,又大遮挡视野,重写了一下,因为用了大量ai留着自用不提交了。 plugin-navidrome-0.1.0-SNAPSHOT.jar
抖音续火花脚本
项目https://github.com/2061360308/DouYinSparkFlow,由于抖音风控作者预制的方法无法使用,于是部署在服务器上,由于是arm架构还挺麻烦的不过家用宽带应该不会风控了。
适用环境:
系统架构:aarch64 / arm64(x86_64 同样适用) 操作系统:Ubuntu 22.04 / Debian 系 内存:≥ 3.8 GB 已能联网访问 creator.douyin.com
⚠️ 不要走 Docker、不要走系统 chromium-browser 包、不要走 snap chromium。这三个坑在本项目里都不通,原因见文末「附录:踩过的坑」。
0. 准备工作
0.1 拉取代码
cd ~
git clone https://github.com/12userzhou/DouYinSparkFlow.git douyinsparkflow
# 如果是你自己 fork 的仓库,把 URL 换成 fork 地址
cd douyinsparkflow0.2 关闭 GitHub Action 定时
⚠️ 本项目默认带 3 个 GitHub Actions 定时工作流(schedule.yml / schedule_api.yml / schedule_dev.yml),每天都会自动跑一次。既然改用本地 crontab 部署,必须关掉 GitHub 这边的定时,否则会容易触发抖音风控导致 cookie 失效
方式 A:GitHub UI 禁用(推荐,30 秒搞定) 不需要任何 git 操作,直接在网页上点:
打开 https://github.com/<你的用户名>/DouYinSparkFlow/actions
左侧列表依次点这三个 workflow:
DouYin Spark Flow Schedule Run
【api(测试)分支】DouYin Spark Flow Schedule Run
【dev(测试)分支】DouYin Spark Flow Schedule Run
进每个 workflow 后,点右上角 ... → Disable workflow
禁用后定时和手动触发都会停止,本地 crontab 不受影响。做完这一步就够了,下面的方式 B 可以跳过。
方式 B:改 workflow 文件后 push(可选,需带 workflow scope 的 PAT) 把"已禁用定时"的状态固化进代码仓库,可以编辑三个 workflow 文件,把 schedule: 触发器注释掉:
on:
workflow_dispatch: # 允许手动触发
# 已改为本地 crontab 部署,禁用 GitHub Action 定时
# schedule: # 定时任务
# - cron: "0 19 * * *"然后提交并推送
git add .github/workflows/
git commit -m "ci: 禁用 GitHub Actions 定时触发"
git push origin main⚠️ 注意:push 含 .github/workflows/ 的改动要求 PAT 带 workflow scope,否则会报 refusing to allow a Personal Access Token to create or update workflow ... without workflow scope。去 https://github.com/settings/tokens 勾上 workflow scope 重新生成即可。不想折腾 token 的话,直接用方式 A,效果一样。
1. 一键部署环境
仓库里有现成的 setup-native.sh,它会自动完成以下 5 步:
安装系统依赖(Python venv + Playwright 运行库 libnss3 / libatk / libgbm 等)
创建 Python 虚拟环境(用 --without-pip + get-pip.py 兜底,绕开 Ubuntu 22.04 上 venv 不完整的问题)
用阿里云镜像装 Python 依赖
用 Playwright 官方 CDN 下载 arm64 chromium headless_shell
创建 logs 目录
直接执行:
bash setup-native.sh关键点:
chromium 必须用
PLAYWRIGHT_DOWNLOAD_HOST=https://playwright.azureedge.net走官方 CDN,国内 npmmirror 镜像缺 arm64 headless_shell 包会 404。venv 必须用
--without-pip,否则在部分 Ubuntu 22.04 上 venv 创建不完整(只有 python 软链,没有 activate / pip)。不要装系统的 chromium-browser 包,它在 arm64 上是个 wrapper 脚本会报错,而且 snap 版本受 GLIBC 限制跑不起来。
部署成功后,会看到类似输出:
==========================================
部署完成!
==========================================2. 创建 .env 配置文件
在项目根目录创建 .env,模板可参考 .env.example。下面是一份实测可用的最小配置示例:
# ===== 普通变量 =====
# 消息内容(不需要一言就直接写文字)
MESSAGE_TEMPLATE=早上好
# 一言类型(MESSAGE_TEMPLATE 不含 [API] 时这个字段会被忽略)
HITOKOTO_TYPES=["文学","影视","诗词","哲学"]
# 匹配模式:short_id = 按抖音号匹配(推荐,比 nickname 稳定)
MATCH_MODE=short_id
BROWSER_TIMEOUT=120000
FRIEND_LIST_WAIT_TIME=2000
TASK_RETRY_TIMES=3
LOG_LEVEL=INFO
# ===== 任务列表 =====
# username 是备注名(日志里用);unique_id 用于关联下面的 COOKIES_
# targets 里填好友的【抖音号 unique_id】或【纯数字 short_id】,两种都能匹配
TASKS=[{"username":"不偷井盖(吃香菜版)","unique_id":"u1","targets":["抖音号1","抖音号2"]}]
# ===== Cookie =====
# 从浏览器开发者工具导出的 Cookie JSON 数组(数组里每个对象要有 name/value/domain/path)
COOKIES_U1=[{"name":"sessionid","value":"替换成真实值","domain":".douyin.com","path":"/"}, ...]获取cookie原本文档里有。
3. 手动运行验证
cd ~/douyinsparkflow
source venv/bin/activate
python -u main.py 2>&1 | tee /tmp/run.log看到这样的日志说明发送成功:
[browser] 使用 Playwright headless_shell: /home/cat/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
INFO - 开始执行任务
INFO - 开始处理账号 不偷井盖(吃香菜版)
INFO - 监听到 user_detail 接口响应,user_list 共 15 条
INFO - 选中目标好友 槐序 (61905275090) 准备开始交互
INFO - 已选中好友 槐序,准备发送消息
INFO - 准备输入消息给好友 槐序:'早上好'
INFO - 输入框实际内容:'早上好'
INFO - 给好友 槐序 发送消息完成(输入框已清空)
...
INFO - 任务完成每个好友都会经历「选中 → 输入 → 验证输入框内容 → 回车发送 → 清空确认」五个步骤。
4. 后续维护
4.1 设置每天 4 点定时执行
# 编辑 crontab
crontab -e加入下面这行(注意把路径换成你的真实路径)
0 4 * * * cd /home/cat/douyinsparkflow && /home/cat/douyinsparkflow/venv/bin/python main.py >> /home/cat/douyinsparkflow/logs/cron.log 2>&1保存退出。验证:
crontab -l4.2 更新 Cookie(最常用的维护操作)
Cookie 过期后,重新从浏览器导出,然后二选一:
方式 A:用脚本(推荐)
cd ~/douyinsparkflow
bash update_cookie.sh
# 把新 cookie JSON 粘进去,Ctrl+D 结束方式 B:手动编辑
nano ~/douyinsparkflow/.env
# 找到 COOKIES_U1= 那一行,整行替换更新后立即跑一次验证:
source venv/bin/activate && python -u main.py 2>&1 | tee /tmp/run.log4.3 改消息内容
cd ~/douyinsparkflow
# 例如改成"晚安"
sed -i 's|^MESSAGE_TEMPLATE=.*|MESSAGE_TEMPLATE=晚安|' .env4.4 改好友列表
cd ~/douyinsparkflow
# 整行替换 TASKS=
# 注意 targets 里的抖音号结尾如果有 . 必须保留
sed -i 's|^TASKS=.*|TASKS=[{"username":"不偷井盖(吃香菜版)","unique_id":"u1","targets":["",""]}]|' .env增加好友列表打开.env找到tasks
4.5 查看日志
# crontab 的运行日志
tail -f ~/douyinsparkflow/logs/cron.log
# 旧的运行日志(每次手动运行会覆盖)
ls ~/douyinsparkflow/logs/4.6 判断 Cookie 是否过期
跑一次 python -u main.py,如果日志里反复出现
ERROR - 账号 xxx 第 1/3 次检测到登录/扫码页面,cookie 可能已失效
ERROR - 账号 xxx 第 2/3 次检测到登录/扫码页面,cookie 可能已失效
ERROR - 账号 xxx 第 3/3 次检测到登录/扫码页面,cookie 可能已失效就是 Cookie 过期了,按 4.2 节更新即可。
5. 常见问题
Q1:ModuleNotFoundError: No module named 'dotenv'
没有激活 venv,或者 venv 没装好。
cd ~/douyinsparkflow
source venv/bin/activate
python -u main.pyQ2:chromium-headless-shell-linux-arm64.zip 404
下载源不对,必须用官方 CDN:
source venv/bin/activate
PLAYWRIGHT_DOWNLOAD_HOST=https://playwright.azureedge.net playwright install chromiumQ3:Missing X server
启用了完整 chromium 而不是 headless_shell。项目 core/browser.py 已经做了自动选择,确保 ~/.cache/ms-playwright/chromium_headless_shell-*/ 目录存在即可。
Q4:requires the chromium snap to be installed
误用了系统 chromium。卸载 snap chromium:
sudo snap remove chromiumQ5:只给第一个好友发了消息
检查 .env 里 TASKS 的 targets 数组是不是真的有多个元素,以及抖音号结尾的 . 有没有漏掉。
Q6:日志里出现 解析响应失败: Response.json: Target page, context or browser has been closed
无害噪音,浏览器关闭时残留接口响应触发的,已经做了静默处理,可以忽略
Q7:crontab 不执行
确认 crond 服务在跑:sudo systemctl status cron 确认 venv 路径对:ls /home/cat/douyinsparkflow/venv/bin/python 看日志:tail -f /home/cat/douyinsparkflow/logs/cron.log
Q8:GitHub Action 还在定时跑,和本地 crontab 重复发送
说明 workflow 没被禁用。最快的解决办法是去 GitHub UI 直接禁用(不需要 git 操作),见本文档「0.3 关闭 GitHub Action 定时 → 方式 A」。 如果走方式 B(改 workflow 文件 push),push 时报 refusing to allow a Personal Access Token to create or update workflow ... without workflow scope,说明你的 PAT 没 workflow scope,去 token 设置页勾上重新生成就行;不想折腾 token 的话直接用方式 A,效果一样。
附录:踩过的坑(仅供避雷,无需操作)
最终的正确方案就是本教程第 1 节的 setup-native.sh:Playwright 官方 CDN 下载 arm64 headless_shell + --without-pip 创建 venv。
安全防护
使用了Fail2Ban(SSH 防爆破 + 实时告警) 和 rkhunter(挖矿 / 后门定期扫描)配合Server 酱消息推送。
Fail2Ban
安装
sudo apt update
sudo apt install -y fail2ban配置 SSH 防护规则
新建本地配置文件(不要修改默认的jail.conf,系统升级会被覆盖):
粘贴以下配置:
[DEFAULT]
bantime = 3600 # 封禁时长:3600秒=1小时
findtime = 600 # 10分钟内统计失败次数
maxretry = 5 # 最多失败5次就封禁
[sshd]
enabled = true
port = ssh # 如果你的SSH不是22端口,改成实际端口号
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
action = %(action_)s
wechat触发封禁时自动发微信
sudo nano /etc/fail2ban/action.d/wechat.conf粘贴内容,替换你的SendKey
[Definition]
actionban = curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=SSH封禁告警&desp=IP <ip> 连续登录失败,已被封禁1小时" > /dev/null
actionunban = curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=SSH解封通知&desp=IP <ip> 封禁到期已自动解封" > /dev/null加执行权限
sudo chmod +x /etc/profile.d/login_alert.sh设置开机自启
sudo systemctl enable --now fail2ban常用管理命令
# 查看SSH防护状态、已封禁IP
sudo fail2ban-client status sshd
# 手动解封某个IP
sudo fail2ban-client set sshd unbanip 要解封的IP地址rkhunter
安装
sudo apt install -y rkhunter配置
初始化系统
sudo rkhunter --update
sudo rkhunter --propupd配置每日自动扫描 + 微信告警
sudo nano /etc/cron.daily/rkhunter_scan.sh粘贴内容
#!/bin/bash
# 执行静默扫描,只保留警告信息
rkhunter --check --skip-keypress --report-warnings-only > /tmp/rkhunter_result.log 2>/dev/null
# 有异常则推送微信
if [ -s /tmp/rkhunter_result.log ]; then
content=$(cat /tmp/rkhunter_result.log | tr '\n' '<br>')
curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=安全扫描告警&desp=$content" > /dev/null
fi给脚本加执行权限:
sudo chmod +x /etc/cron.daily/rkhunter_scan.sh手动扫描命令
sudo rkhunter --check --skip-keypress --report-warnings-only \
--disable suspscan,hidden_procs,deleted_procs \
--exclude-dir=/var/lib/docker \
--exclude-dir=/mnt \
--exclude-dir=/media \
--exclude-dir=/tmp/ShellCrash优化规则写入配置文件
sudo tee -a /etc/rkhunter.conf << 'EOF'
# 排除大体积/外部挂载目录
EXCLUDE_DIR=/var/lib/docker
EXCLUDE_DIR=/mnt
EXCLUDE_DIR=/media
EXCLUDE_DIR=/tmp/ShellCrash
# 禁用耗时且低优先级的检测项
DISABLE_TESTS=suspscan hidden_procs deleted_procs
EOF硬件
搞了一块ST7789 屏幕显示ip地址、温度和内存、cpu使用率但是感觉没必要拔了,还是分享一下吧毕竟不会搞硬件插错好多次以为写错了搞了好久。
40-Pin 物理接线图
系统配置
环境配置文件路径:/boot/uEnv.txt
#26pin
overlays=h618-lubancat-spi1-overlay.dtbo
#格式:overlays=xxx.dtbo xxx.dtbo xxx.dtbo
#2选1:h618-lubancat-i2c4-overlay.dtbo h618-lubancat-uart2-overlay.dtbo
#2选1:h618-lubancat-i2c2-overlay.dtbo h618-lubancat-uart5-overlay.dtbo
#h618-lubancat-spi1-overlay.dtbo在终端输入命令新建文件
nano st7789_pro.py代码如下
import time
import spidev
import os
import socket
import shutil
import numpy as np
import subprocess
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
# --- 原生 Sysfs GPIO 驱动器 ---
class SysfsGPIO:
def init(self, pin_number):
self.pin = str(pin_number)
self.path = f"/sys/class/gpio/gpio{self.pin}"
if not os.path.exists(self.path):
try:
with open("/sys/class/gpio/export", "w") as f:
f.write(self.pin)
time.sleep(0.1)
except Exception:
pass
with open(f"{self.path}/direction", "w") as f:
f.write("out")
self.val_file = open(f"{self.path}/value", "w")
def set_value(self, value):
self.val_file.write("1" if value else "0")
self.val_file.flush()
def release(self):
self.val_file.close()
try:
with open("/sys/class/gpio/unexport", "w") as f:
f.write(self.pin)
except:
pass
# --- ST7789 驱动类 ---
class ST7789_SafeDriver:
def init(self, spi_bus=1, spi_device=0, dc_pin=72, res_pin=75):
self.spi = spidev.SpiDev()
self.spi.open(spi_bus, spi_device)
self.spi.max_speed_hz = 40000000
self.spi.mode = 0
self.dc = SysfsGPIO(dc_pin)
self.res = SysfsGPIO(res_pin)
self.width = 240
self.height = 240
self._init_display()
def sendcmd(self, cmd):
self.dc.set_value(0)
self.spi.writebytes([cmd])
def senddata(self, data):
self.dc.set_value(1)
if isinstance(data, int):
self.spi.writebytes([data])
else:
self.spi.writebytes(data)
def initdisplay(self):
self.res.set_value(1)
time.sleep(0.1)
self.res.set_value(0)
time.sleep(0.1)
self.res.set_value(1)
time.sleep(0.1)
self._send_cmd(0x11)
time.sleep(0.12)
self._send_cmd(0x36)
self._send_data(0x00)
self._send_cmd(0x3A)
self._send_data(0x05)
self._send_cmd(0x21)
self._send_cmd(0x13)
self._send_cmd(0x29)
def set_window(self, x0, y0, x1, y1):
self._send_cmd(0x2A)
self._send_data([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF])
self._send_cmd(0x2B)
self._send_data([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF])
self._send_cmd(0x2C)
def display_image(self, img):
img = img.resize((self.width, self.height)).convert("RGB")
r, g, b = img.split()
r_arr = np.array(r, dtype=np.uint16) >> 3
g_arr = np.array(g, dtype=np.uint16) >> 2
b_arr = np.array(b, dtype=np.uint16) >> 3
rgb565 = (r_arr << 11) | (g_arr << 5) | b_arr
rgb565 = rgb565.byteswap()
self.set_window(0, 0, self.width - 1, self.height - 1)
self.dc.set_value(1)
self.spi.writebytes2(rgb565.tobytes())
def close(self):
try:
self.spi.close()
self.dc.release()
self.res.release()
except:
pass
# --- 数据获取函数 ---
def get_ip_address():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except:
return "Offline"
def get_cpu_temp():
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
return round(float(f.read()) / 1000.0, 1)
except:
return 0.0
def get_ram_usage():
try:
out = subprocess.check_output(['free', '-m']).decode('utf-8').splitlines()[1]
parts = out.split()
return int(parts[2]), int(parts[1]), round((int(parts[2]) / int(parts[1])) * 100, 1)
except:
return 0, 0, 0.0
def get_disk_usage():
try:
total, used, free = shutil.disk_usage("/")
gb = 2**30
return round(used/gb, 1), round(total/gb, 1), int((used/total)*100)
except:
return 0.0, 0.0, 0
if __name__ == "__main__":
disp = None
try:
disp = ST7789_SafeDriver()
# 加载系统高清字体
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
try:
font_title = ImageFont.truetype(font_path, 18)
font_main = ImageFont.truetype(font_path, 14)
font_small = ImageFont.truetype(font_path, 12)
except:
font_title = font_main = font_small = ImageFont.load_default()
while True:
image = Image.new("RGB", (240, 240), "black")
draw = ImageDraw.Draw(image)
# 获取数据
cur_time = datetime.now().strftime("%H:%M:%S")
ip_addr = get_ip_address()
cpu_temp = get_cpu_temp()
ram_used, ram_total, ram_pct = get_ram_usage()
disk_used, disk_total, disk_pct = get_disk_usage()
# UI 绘制
draw.rectangle((2, 2, 237, 237), outline="#3399FF", width=2)
# 顶部栏 (时间 + IP)
draw.rectangle((2, 2, 237, 30), fill="#3399FF")
draw.text((10, 5), cur_time, fill="white", font=font_title)
draw.text((100, 7), ip_addr, fill="white", font=font_main)
# CPU 温度
t_color = "#FF3333" if cpu_temp > 65 else "#00FF00"
draw.text((15, 45), f"CPU Temp: {cpu_temp} °C", fill=t_color, font=font_title)
# RAM 内存条
draw.text((15, 85), f"RAM: {ram_used}M / {ram_total}M", fill="#FFFF00", font=font_main)
draw.rectangle((15, 105, 220, 120), outline="white")
draw.rectangle((15, 105, 15 + int(205 * (ram_pct / 100)), 120), fill="#00FFFF")
draw.text((105, 106), f"{ram_pct}%", fill="black", font=font_small)
# 磁盘存储条
draw.text((15, 140), f"SD Card: {disk_used}G / {disk_total}G", fill="#FF99FF", font=font_main)
draw.rectangle((15, 160, 220, 175), outline="white")
draw.rectangle((15, 160, 15 + int(205 * (disk_pct / 100)), 175), fill="#FF66FF")
draw.text((105, 161), f"{disk_pct}%", fill="black", font=font_small)
# 底部标识
draw.line((10, 205, 230, 205), fill="#555555")
draw.text((45, 212), "LuBanCat System Pro", fill="#888888", font=font_small)
disp.display_image(image)
time.sleep(2)
except KeyboardInterrupt:
pass
finally:
if disp:
# 退出时刷黑屏幕保护硬件
disp.display_image(Image.new("RGB", (240, 240), "black"))
disp.close()操作
开灯运行监控:
sudo systemctl start st7789_lcd.service 关灯黑屏休息:
sudo systemctl stop st7789_lcd.service 允许开机自动亮起:
sudo systemctl enable st7789_lcd.service 禁止开机自动亮起:
sudo systemctl disable st7789_lcd.service 强行杀死残留进程(如果stop后没黑屏):