Skill: t-evan/netease-music-pusher (version 1.0.0)

# 网易云音乐推送技能 自动获取网易云音乐每日推荐并推送，支持验证码登录获取个性化日推。 ## 功能特性 - ✅ **个性化日推** - 登录后获取专属每日推荐 - ✅ **验证码登录** - 无需密码，短信验证码安全登录 - ✅ **自动Cookie管理** - 登录状态自动保存和恢复 - ✅ **定时推送** - 可配置定时任务自动推送 - ✅ **多榜单支持** - 飙升榜、新歌榜、热歌榜等公开榜单 - ✅ **丰富歌曲信息** - 歌曲链接、推荐理由、别名标签 ## 推送内容 每日推送包含以下信息： | 字段 | 说明 | 示例 | |------|------|------| | 歌曲名 | 歌曲标题 | "Yellow" | | 歌手 | 演唱者 | "Coldplay" | | 专辑 | 所属专辑 | "Yellow" | | 歌曲链接 | 网易云直达链接 | `https://music.163.com/song?id=xxx` | | 推荐理由 | 热度指标 | "十万红心"、"超85%人播放" | | 别名标签 | 歌曲别名/备注 | "电影《阳光小美女》最热配乐" | ### 关于风格标签 **当前状态**: 网易云官方API对歌曲风格标签的支持有限 **已有信息**: - 推荐理由（反映歌曲热度） - alia别名（部分歌曲有电影/场景标注） **替代方案**: 推荐理由已能很好地反映歌曲的流行程度和用户喜好 ## 快速开始 ### 1. 安装依赖 ```bash pip3 install cryptography ``` ### 2. 登录网易云 ```bash cd /root/.openclaw/workspace # 发送验证码 python3 skills/netease-music-pusher/scripts/netease_client.py send_captcha <手机号> # 使用验证码登录 python3 skills/netease-music-pusher/scripts/netease_client.py login <手机号> <验证码> ``` ### 3. 获取日推 ```bash # 获取个性化日推（需要登录） python3 skills/netease-music-pusher/scripts/netease_client.py daily # 获取公开榜单（无需登录） python3 skills/netease-music-pusher/scripts/netease_client.py toplist ``` ## 配置定时任务 添加OpenClaw定时任务，每天自动推送： ```bash # 任务配置示例 { "name": "网易云日推推送", "schedule": "0 8 * * *", "command": "python3 skills/netease-music-pusher/scripts/netease_client.py daily" } ``` ## 文件说明 ``` netease-music-pusher/ ├── SKILL.md # 本说明文档 ├── scripts/ │ ├── netease_client.py # 主客户端（支持登录+日推） │ └── netease_public_api.py # 公开API（无需登录） └── README.md # 详细使用说明 ``` ## 技术细节 ### 登录流程 1. **发送验证码** - 调用 `/weapi/sms/captcha/sent` - 网易云发送短信验证码到手机 2. **验证码登录** - 调用 `/weapi/login/cellphone` - 提交手机号+验证码 - 成功后获取Cookies 3. **保存登录状态** - 保存到 `secrets/netease_cookies.json` - 后续自动加载，无需重复登录 ### 获取日推 - 接口: [REDACTED_SECRET] - 需要: 登录后的Cookies - 返回: 每日推荐歌曲列表（通常20-30首） ### 加密算法 网易云API使用自定义加密： - **AES-CBC**: 两次加密，密钥分别为固定值和随机值 - **RSA**: 加密随机密钥 - **填充**: PKCS7 具体实现见 `netease_client.py` 中的 `NeteaseCrypto` 类。 ## 常见问题 ### Q: 验证码收不到？ A: 可能原因： - 手机号输入错误 - 网易云风控限制（短时间内发送次数过多） - 运营商短信拦截 **解决**: 等待几分钟后重试，或尝试其他时间段。 ### Q: 登录过期了？ A: Cookies通常7-30天有效，过期后需要重新登录。 **解决**: 重新执行验证码登录流程。 ### Q: 可以密码登录吗？ A: 当前版本仅支持验证码登录，更安全且无需存储密码。 ### Q: 二维码登录支持吗？ A: 网易云已限制二维码登录API（返回8821错误），建议使用验证码登录。 ## 更新记录 - **2026-02-18** - 初版发布，支持验证码登录和日推获取 ## 参考 - 网易云音乐API逆向参考 - `cryptography` 库文档 - OpenClaw定时任务配置

path: README.md
content: # 网易云音乐推送技能

> 一键获取网易云每日推荐，支持验证码登录获取个性化内容

## 🚀 快速开始

### 1. 安装依赖

```bash
pip3 install cryptography
```

### 2. 首次登录

```bash
cd /root/.openclaw/workspace

# Step 1: 发送验证码
python3 skills/netease-music-pusher/scripts/netease_client.py send_captcha 17651730816

# Step 2: 收到验证码后登录（把7725换成你收到的验证码）
python3 skills/netease-music-pusher/scripts/netease_client.py login 17651730816 7725
```

### 3. 获取日推

```bash
# 获取个性化日推
python3 skills/netease-music-pusher/scripts/netease_client.py daily
```

输出示例：
```
🎵 网易云日推
📅 02月18日
💝 专属于你的每日推荐
========================================

 1. Yellow
    🎤 Coldplay
    💿 Yellow
    💡 昨日十万播放
    🔗 https://music.163.com/song?id=17177324

 2. 春的女孩
    🎤 生石灰拌蛋1.0
    💿 春的女孩
    💡 超85%人播放
    🔗 https://music.163.com/song?id=2071222896

 9. The Winner Is
    🎤 Mychael Danna
    💿 Little Miss Sunshine
    🏷️ 电影《阳光小美女》最热配乐
    🔗 https://music.163.com/song?id=3038433

...
```

### 推送内容说明

| 信息 | 说明 |
|------|------|
| 🎤 歌手 | 演唱者名称 |
| 💿 专辑 | 所属专辑 |
| 💡 推荐理由 | 热度指标（如"十万红心"、"超85%人播放"）|
| 🏷️ 别名标签 | 歌曲别名或场景标注 |
| 🔗 歌曲链接 | 点击直达网易云音乐 |

**关于风格标签**: 网易云API对歌曲风格标签的支持有限，当前通过推荐理由和别名标签来反映歌曲特点和热度。

## 📋 命令说明

### netease_client.py

| 命令 | 说明 | 示例 |
|------|------|------|
| `send_captcha <手机号>` | 发送验证码 | `send_captcha 17651730816` |
| `login <手机号> <验证码>` | 验证码登录 | `login 17651730816 7725` |
| `daily` | 获取日推 | `daily` |
| `status` | 检查登录状态 | `status` |

### netease_public_api.py（无需登录）

| 命令 | 说明 |
|------|------|
| `daily` | 获取飙升榜 |
| `飙升榜` | 飙升榜 |
| `新歌榜` | 新歌榜 |
| `原创榜` | 原创榜 |
| `热歌榜` | 热歌榜 |

## ⏰ 配置定时推送

### OpenClaw定时任务

```bash
# 查看当前任务
openclaw cron list

# 添加网易云日推任务（每天8:00推送）
openclaw cron add \
  --name "网易云日推" \
  --schedule "0 8 * * *" \
  --command "python3 skills/netease-music-pusher/scripts/netease_client.py daily"
```

或使用cron工具：

```bash
# 添加任务（北京时间每天8:00）
curl -X POST "http://localhost:8000/cron/jobs" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "网易云日推推送",
    "schedule": "0 8 * * *",
    "timezone": "Asia/Shanghai",
    "command": "cd /root/.openclaw/workspace && python3 skills/netease-music-pusher/scripts/netease_client.py daily"
  }'
```

## 🔧 技术实现

### 登录流程

```
1. 发送验证码
   POST /weapi/sms/captcha/sent
   { cellphone: "手机号", ctcode: "86" }
   
2. 验证码登录
   POST /weapi/login/cellphone
   { phone: "手机号", captcha: "验证码", countrycode: "86" }
   
3. 保存Cookies → secrets/netease_cookies.json
```

### 日推接口

```
POST /weapi/v1/discovery/recommend/songs
Headers: Cookies (MUSIC_U, __csrf等)
返回: { code: 200, data: { dailySongs: [...] } }
```

### 加密算法

网易云API使用两层加密：

```python
# 1. AES-CBC加密（两次）
text = json.dumps(params)
enc1 = AES.encrypt(text, NONCE)  # 固定密钥
enc2 = AES.encrypt(enc1, secKey) # 随机密钥

# 2. RSA加密随机密钥
encSecKey = RSA.encrypt(secKey, PUBKEY, MODULUS)

# 3. 发送
POST data: { params: enc2, encSecKey: encSecKey }
```

## 📁 文件结构

```
skills/netease-music-pusher/
├── SKILL.md                    # 技能说明
├── README.md                   # 本文件
└── scripts/
    ├── netease_client.py      # 主客户端（登录+日推）
    └── netease_public_api.py  # 公开榜单API
```

## ⚠️ 注意事项

1. **登录有效期**: Cookies通常7-30天有效，过期后需要重新登录
2. **验证码频率**: 不要频繁发送验证码，避免触发风控
3. **账号安全**: 验证码登录比密码登录更安全，不存储敏感信息
4. **网络要求**: 需要能访问网易云音乐域名（music.163.com）

## 🔍 故障排查

### 验证码收不到

- 检查手机号是否正确
- 等待2-3分钟后重试
- 检查手机短信拦截设置

### 登录失败

- 验证码是否正确（区分大小写）
- 验证码是否过期（5分钟有效）
- 检查网络连接

### 日推获取失败

- 检查登录状态: `python3 scripts/netease_client.py status`
- 如已过期，重新执行登录流程

### 风格标签缺失

**现象**: 推送中缺少歌曲风格标签

**原因**: 网易云官方API对歌曲风格标签的支持有限，大部分歌曲不返回风格字段

**当前替代方案**:
- 推荐理由（如"十万红心"、"超85%人播放"）反映歌曲热度
- 别名标签（如"电影《阳光小美女》最热配乐"）标注特殊场景

**技术限制**: 已通过多种API接口测试，包括歌曲详情、歌单信息、歌手信息等，均无法稳定获取风格标签

## 📝 更新计划

- [ ] 支持多账号切换
- [ ] 支持歌单推送
- [ ] 支持歌曲搜索
- [ ] 支持歌词获取

## 📄 License

MIT


path: scripts/netease_client.py
content: #!/usr/bin/env python3
"""
网易云音乐API - 支持登录获取个性化日推
实现了网易云weapi的加密逻辑
"""
import requests
import json
import base64
import os
from datetime import datetime
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

class NeteaseCrypto:
    """网易云加密工具"""
    
    # 网易云固定密钥
    MODULUS = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
    NONCE = '0CoJUm6Qyw8W8jud'
    PUBKEY = '010001'
    IV = '0102030405060708'
    
    @staticmethod
    def aes_encrypt(text, key):
        """AES-CBC加密"""
        # PKCS7填充
        pad_len = 16 - len(text) % 16
        text = text + chr(pad_len) * pad_len
        
        cipher = Cipher(
            algorithms.AES(key.encode('utf-8')),
            modes.CBC(NeteaseCrypto.IV.encode('utf-8')),
            backend=default_backend()
        )
        encryptor = cipher.encryptor()
        ciphertext = encryptor.update(text.encode('utf-8')) + encryptor.finalize()
        return base64.b64encode(ciphertext).decode('utf-8')
    
    @staticmethod
    def rsa_encrypt(text, pubkey, modulus):
        """RSA加密"""
        text = text[::-1]  # 反转字符串
        text_bytes = text.encode('utf-8')
        text_int = int.from_bytes(text_bytes, 'big')
        
        pubkey_int = int(pubkey, 16)
        modulus_int = int(modulus, 16)
        
        result = pow(text_int, pubkey_int, modulus_int)
        return format(result, 'x').zfill(256)
    
    @staticmethod
    def encrypt(params):
        """网易云weapi加密"""
        # 生成随机密钥 (16位小写字母)
        sec_key = ''.join([chr(ord('a') + (os.urandom(1)[0] % 26)) for _ in range(16)])
        
        # 第一次AES加密
        text = json.dumps(params)
        enc_text = NeteaseCrypto.aes_encrypt(text, NeteaseCrypto.NONCE)
        
        # 第二次AES加密
        enc_text = NeteaseCrypto.aes_encrypt(enc_text, sec_key)
        
        # RSA加密密钥
        enc_sec_key = NeteaseCrypto.rsa_encrypt(sec_key, NeteaseCrypto.PUBKEY, NeteaseCrypto.MODULUS)
        
        return {
            'params': enc_text,
            'encSecKey': enc_sec_key
        }

class NeteaseMusicClient:
    """网易云音乐客户端"""
    
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0',
            'Referer': 'https://music.163.com/',
            'Origin': 'https://music.163.com',
            'Accept': '*/*',
            'Accept-Language': 'zh-CN,zh;q=0.9',
            'Content-Type': 'application/x-www-form-urlencoded'
        })
        self.crypto = NeteaseCrypto()
        self.cookies_file = '/root/.openclaw/workspace/secrets/netease_cookies.json'
        
    def weapi_request(self, endpoint, params=None):
        """调用weapi接口"""
        url = f'https://music.163.com/weapi{endpoint}'
        params = params or {}
        
        # csrf_token
        cookies = self.session.cookies.get_dict()
        params['csrf_token'] = cookies.get('__csrf', '')
        
        # 加密参数
        data = self.crypto.encrypt(params)
        
        try:
            response = self.session.post(url, data=data, timeout=15)
            return response.json()
        except Exception as e:
            print(f"❌ 请求失败: {e}")
            return {'code': -1, 'msg': str(e)}
    
    def send_captcha(self, phone):
        """发送验证码"""
        result = self.weapi_request('/sms/captcha/sent', {
            'cellphone': phone,
            'ctcode': '86'
        })
        
        if result.get('code') == 200:
            print(f"✅ 验证码已发送到 {phone}")
            print("⏰ 验证码5分钟内有效，请查收短信")
            return True
        else:
            msg = result.get('message', result.get('msg', '未知错误'))
            print(f"❌ 发送失败: {msg}")
            return False
    
    def login_with_captcha(self, phone, captcha):
        """使用验证码登录"""
        result = self.weapi_request('/login/cellphone', {
            'phone': phone,
            'captcha': captcha,
            'countrycode': '86',
            'rememberLogin': 'true'
        })
        
        if result.get('code') == 200:
            profile = result.get('profile', {})
            nickname = profile.get('nickname', '用户')
            print(f"✅ 登录成功！欢迎回来，{nickname}～")
            self.save_cookies()
            return True
        else:
            msg = result.get('message', result.get('msg', '未知错误'))
            print(f"❌ 登录失败: {msg}")
            return False
    
    def get_daily_recommend(self):
        """获取每日推荐歌曲"""
        result = self.weapi_request('/v1/discovery/recommend/songs', {
            'offset': 0,
            'total': True,
            'limit': 20
        })
        
        if result.get('code') == 200:
            data = result.get('data', {})
            return data.get('dailySongs', [])
        return []
    
    def get_song_detail(self, song_ids):
        """获取歌曲详情（包含风格标签）"""
        if isinstance(song_ids, list):
            ids = ','.join([str(id) for id in song_ids])
        else:
            ids = str(song_ids)
        
        result = self.weapi_request('/v3/song/detail', {
            'c': json.dumps([{'id': int(id)} for id in ids.split(',')]),
            'ids': ids
        })
        
        if result.get('code') == 200:
            return result.get('songs', [])
        return []
    
    def get_song_url(self, song_id):
        """获取歌曲播放链接"""
        result = self.weapi_request('/song/enhance/player/url', {
            'ids': [song_id],
            'br': 320000
        })
        
        if result.get('code') == 200:
            data = result.get('data', [])
            if data:
                return data[0].get('url')
        return None
    
    def save_cookies(self):
        """保存登录状态"""
        os.makedirs(os.path.dirname(self.cookies_file), exist_ok=True)
        # 处理重复的cookie，只保留第一个
        cookies_dict = {}
        for cookie in self.session.cookies:
            if cookie.name not in cookies_dict:
                cookies_dict[cookie.name] = cookie.value
        with open(self.cookies_file, 'w') as f:
            json.dump(cookies_dict, f)
        print("💾 登录状态已保存")
    
    def load_cookies(self):
        """加载登录状态"""
        try:
            with open(self.cookies_file, 'r') as f:
                cookies = json.load(f)
                self.session.cookies.update(cookies)
                return True
        except (FileNotFoundError, json.JSONDecodeError):
            return False

def format_daily_songs(songs, date_str=None):
    """格式化日推歌曲"""
    if not songs:
        return "❌ 暂无推荐歌曲"
    
    date = date_str or datetime.now().strftime('%m月%d日')
    
    lines = [
        "🎵 网易云日推",
        f"📅 {date}",
        "💝 专属于你的每日推荐",
        "=" * 40,
        ""
    ]
    
    for i, song in enumerate(songs[:10], 1):
        name = song.get('name', '未知')
        song_id = song.get('id', '')
        song_url = f"https://music.163.com/song?id={song_id}" if song_id else ''
        
        artists = song.get('artists', [])
        artist_names = ' / '.join([a.get('name', '未知') for a in artists[:2]])
        
        album = song.get('album', {})
        album_name = album.get('name', '')
        
        # 获取风格标签（如果有）
        tags = []
        if 'tags' in song and song['tags']:
            tags = song['tags']
        elif 'genre' in song and song['genre']:
            tags = [song['genre']]
        
        # 获取推荐理由
        reason = song.get('reason', '')
        
        lines.append(f"{i:2d}. {name}")
        lines.append(f"    🎤 {artist_names}")
        if album_name:
            lines.append(f"    💿 {album_name}")
        if tags:
            lines.append(f"    🏷️ {' | '.join(tags[:3])}")
        if reason:
            lines.append(f"    💡 {reason}")
        if song_url:
            lines.append(f"    🔗 {song_url}")
        lines.append("")
    
    lines.extend([
        "─" * 40,
        "🎧 打开网易云音乐App收听完整版",
        "🌟 每日6:00更新，错过就听不到了哦～"
    ])
    
    return '\n'.join(lines)

def main():
    import sys
    client = NeteaseMusicClient()
    
    if len(sys.argv) < 2:
        print("用法:")
        print("  python3 netease_client.py send_captcha <手机号>")
        print("  python3 netease_client.py login <手机号> <验证码>")
        print("  python3 netease_client.py daily")
        return
    
    cmd = sys.argv[1]
    
    if cmd == 'send_captcha' and len(sys.argv) > 2:
        phone = sys.argv[2]
        client.send_captcha(phone)
        
    elif cmd == 'login' and len(sys.argv) > 3:
        phone = sys.argv[2]
        captcha = sys.argv[3]
        client.login_with_captcha(phone, captcha)
        
    elif cmd == 'daily':
        if client.load_cookies():
            songs = client.get_daily_recommend()
            if songs:
                # 获取歌曲详情（包含风格标签）
                print("正在获取歌曲风格标签...")
                song_ids = [song.get('id') for song in songs[:10] if song.get('id')]
                song_details = client.get_song_detail(song_ids)
                
                # 将详情信息合并到歌曲数据中
                details_map = {s.get('id'): s for s in song_details}
                for song in songs:
                    song_id = song.get('id')
                    if song_id in details_map:
                        detail = details_map[song_id]
                        # 合并风格标签
                        if 'tags' in detail and detail['tags']:
                            song['tags'] = detail['tags']
                        # 从alia或transNames获取风格信息
                        alia = detail.get('alia', [])
                        if alia and not song.get('tags'):
                            song['tags'] = alia[:2]
                
                print(format_daily_songs(songs))
            else:
                print("❌ 获取日推失败，请检查登录状态")
        else:
            print("❌ 未登录，请先使用 login 命令登录")
    
    else:
        print(f"未知命令: {cmd}")

if __name__ == '__main__':
    main()


path: scripts/netease_public_api.py
content: #!/usr/bin/env python3
"""
网易云音乐API - 使用公开API获取榜单数据
无需登录，获取飙升榜、新歌榜等
"""
import requests
import json
from datetime import datetime

class NeteasePublicAPI:
    """网易云公开API - 无需登录"""
    
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            'Referer': 'https://music.163.com/'
        })
    
    def get_toplist_detail(self, list_id):
        """获取榜单详情"""
        url = f'https://music.163.com/api/playlist/detail?id={list_id}'
        try:
            response = self.session.get(url, timeout=10)
            result = response.json()
            if result.get('code') == 200:
                playlist = result.get('result', {})
                tracks = playlist.get('tracks', [])
                return {
                    'name': playlist.get('name', '未知榜单'),
                    'tracks': tracks[:10]
                }
            return None
        except Exception as e:
            print(f"❌ 获取失败: {e}")
            return None
    
    def get_toplist(self, name='飙升榜'):
        """获取指定榜单"""
        # 网易云榜单ID
        lists = {
            '飙升榜': 19723756,
            '新歌榜': 3779629,
            '原创榜': 2884035,
            '热歌榜': 3778678,
            '黑胶VIP爱听榜': 5453912201,
            '云音乐说唱榜': 991319590,
            '云音乐古典榜': 71385702
        }
        
        list_id = lists.get(name, 19723756)
        return self.get_toplist_detail(list_id)

def format_song_list(data, date_str=None):
    """格式化歌曲列表为推送文本"""
    if not data or not data.get('tracks'):
        return "❌ 暂无数据"
    
    name = data.get('name', '榜单')
    tracks = data['tracks']
    date = date_str or datetime.now().strftime('%m月%d日')
    
    lines = [
        f"🎵 网易云音乐 | {name}",
        f"📅 {date}",
        "=" * 40,
        ""
    ]
    
    for i, track in enumerate(tracks[:10], 1):
        song_name = track.get('name', '未知')
        artists = track.get('artists', [])
        artist_names = ' / '.join([a.get('name', '未知') for a in artists[:2]])
        
        # 获取专辑名
        album = track.get('album', {})
        album_name = album.get('name', '')
        
        lines.append(f"{i:2d}. {song_name}")
        lines.append(f"    🎤 {artist_names}")
        if album_name and album_name != song_name:
            lines.append(f"    💿 {album_name}")
        lines.append("")
    
    lines.extend([
        "─" * 40,
        "🎧 打开网易云音乐App收听完整版",
        "💬 有好听的歌记得分享给我哦～"
    ])
    
    return '\n'.join(lines)

def push_daily():
    """推送每日榜单"""
    api = NeteasePublicAPI()
    data = api.get_toplist('飙升榜')
    if data:
        return format_song_list(data)
    return "❌ 获取榜单失败"

if __name__ == '__main__':
    import sys
    
    if len(sys.argv) > 1:
        cmd = sys.argv[1]
        if cmd == 'daily':
            print(push_daily())
        elif cmd in ['飙升榜', '新歌榜', '原创榜', '热歌榜']:
            api = NeteasePublicAPI()
            data = api.get_toplist(cmd)
            print(format_song_list(data))
        else:
            print(f"未知命令: {cmd}")
            print("可用: daily, 飙升榜, 新歌榜, 原创榜, 热歌榜")
    else:
        print(push_daily())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)