Back to skills
SkillHub ClubAnalyze Data & AIFull StackBackendData / AI

meme-scanner

基于 GMGN 官方 API 的 Meme 币扫链工具。自动扫描热门代币,进行 AI 评分与风险分析,并推送格式化通知。完全使用 GMGN API,数据准确可靠。

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,111
Hot score
99
Updated
March 20, 2026
Overall rating
C0.0
Composite score
0.0
Best-practice grade
C61.1

Install command

npx @skill-hub/cli install openclaw-skills-meme-scanner

Repository

openclaw/skills

Skill path: skills/hanguang254/meme-scanner

基于 GMGN 官方 API 的 Meme 币扫链工具。自动扫描热门代币,进行 AI 评分与风险分析,并推送格式化通知。完全使用 GMGN API,数据准确可靠。

Open repository

Best for

Primary workflow: Analyze Data & AI.

Technical facets: Full Stack, Backend, Data / AI.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install meme-scanner into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding meme-scanner to shared team environments
  • Use meme-scanner for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: meme-scanner
version: 2.0.0
description: "基于 GMGN 官方 API 的 Meme 币扫链工具。自动扫描热门代币,进行 AI 评分与风险分析,并推送格式化通知。完全使用 GMGN API,数据准确可靠。"
---

# Meme Scanner v2.0 - 基于 GMGN 官方 API

## 概述

此技能提供全自动化的 Meme 币扫描和分析解决方案。完全使用 GMGN 官方 API,通过浏览器 CDP 绕过 Cloudflare,实现高效准确的数据抓取。

## 核心改进 (v2.0)

### 重大更新
- ✅ 完全使用 GMGN 官方 API
- ✅ 移除 Ave.ai 依赖
- ✅ 通过浏览器 CDP 绕过 Cloudflare
- ✅ 数据更准确可靠
- ✅ 更快的响应速度

## 核心能力

### 1. 数据源
- **GMGN Rank API**: 扫描 1小时交易最活跃代币
- **GMGN Gainers API**: 扫描 24小时涨幅榜
- **GMGN Token API**: 获取代币详细信息
- **GMGN Security API**: 安全检测
- **GMGN Stat API**: 持有者统计

### 2. 智能筛选条件
- 市值:$10K - $5M
- 流动性:≥ $4K
- 持有者:≥ 50
- 24小时涨幅:≥ 100%
- 交易量/市值比:≥ 30%
- Bundler 比例:≤ 50%
- 非蜜罐
- Early Score:≥ 8/10

### 3. AI 评分系统
- **Early Score (1-10分)**: 综合评估代币潜力
  - 流动性评分
  - 市值评分
  - 持有者评分
  - 交易量评分
  - 涨幅评分
  - Bundler 惩罚

### 4. 风险识别
- Bundler 比例过高
- Top10 持仓集中度
- 流动性不足
- 税率异常

## 使用说明

### 前置条件

需要配置浏览器 CDP 连接(与 Token Analyzer 相同):

1. 启动带插件的 Chrome(端口 9222)
2. 配置 OpenClaw 连接到远程 Chrome
3. 确保 websockets 依赖已安装

详细配置请参考 Token Analyzer 技能文档。

### 执行方式

#### 1. 手动执行
```bash
python3 /root/.openclaw/workspace/skills/meme-scanner/scripts/meme_scanner_v2.py
```

#### 2. 定时任务(推荐)
通过 OpenClaw cron 设置定时扫描:
```bash
# 每小时扫描一次
openclaw cron add --schedule "0 * * * *" --task "扫描 Meme 币"
```

### 输出格式

脚本输出 JSON 数组,每个元素是格式化的 Markdown 消息:

```json
[
  "🔔 扫链发现 | SOL\n**Token Name ($SYMBOL)**\n\nCA: ...\n...",
  "🔔 扫链发现 | BSC\n**Token Name ($SYMBOL)**\n\nCA: ...\n..."
]
```

## 技术说明

### 依赖
- Python 3.7+
- websockets
- urllib (标准库)

### API 接口
使用以下 GMGN API:
- `/defi/quotation/v1/rank/{chain}/swaps/1h` - 交易活跃榜
- `/defi/quotation/v1/rank/{chain}/gainers/24h` - 涨幅榜
- `/vas/api/v1/search_v3` - 代币搜索
- `/api/v1/mutil_window_token_security_launchpad` - 安全检测
- `/api/v1/token_stat` - 持有者统计
- `/api/v1/mutil_window_token_link_rug_vote` - 社交链接

### 去重机制
- 使用 `scanned_tokens.json` 记录已扫描代币
- 24小时内不重复推送同一代币
- 自动清理过期记录

## 配置调整

可在脚本中调整以下参数:

```python
MIN_MCAP = 10000        # 最小市值
MAX_MCAP = 5000000      # 最大市值
MIN_LIQUIDITY = 4000    # 最小流动性
MIN_HOLDERS = 50        # 最小持有者
MIN_CHANGE_24H = 100    # 最小24h涨幅
MIN_VOL_MCAP_RATIO = 0.3  # 最小交易量/市值比
MAX_BUNDLER_RATE = 0.5  # 最大Bundler比例
MIN_EARLY_SCORE = 8     # 最小早期得分
```

## 更新日志

### v2.0.0 (2026-03-05)
- ✅ 完全重构为使用 GMGN 官方 API
- ✅ 移除 Ave.ai 依赖
- ✅ 通过浏览器 CDP 绕过 Cloudflare
- ✅ 优化筛选逻辑
- ✅ 改进评分算法
- ✅ 更准确的数据

### v1.0.0
- 初始版本
- 使用 gmgn.ai + Ave.ai


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "hanguang254",
  "slug": "meme-scanner",
  "displayName": "Meme Scanner",
  "latest": {
    "version": "2.0.0",
    "publishedAt": 1772692903928,
    "commit": "https://github.com/openclaw/skills/commit/d66bd72486196b76aca3a407b72232036b56f289"
  },
  "history": [
    {
      "version": "1.1.0",
      "publishedAt": 1772595767713,
      "commit": "https://github.com/openclaw/skills/commit/ed1a1cc772c55cae5fe1aaf20f3d5a7acd3328ad"
    },
    {
      "version": "1.0.4",
      "publishedAt": 1772451531258,
      "commit": "https://github.com/openclaw/skills/commit/8fb493b8219411170f5f5efbc9b61e26efca7d7f"
    }
  ]
}

```

### scripts/meme_scanner.py

```python
#!/usr/bin/env python3
"""
Meme Scanner v1 - 扫链发现有机会的 Meme 代币 (精简版,Why Alpha由 Agent 生成)
数据源: gmgn.ai rank API + Ave.ai trending API
扫描链: SOL, BSC
筛选条件: 市值 $10K~$5M, 流动性>$5K, 持有者>50, 24h涨幅>50%, 交易量/市值>30%, Bundler<70%, 非蜜罐
"""

import json, asyncio, sys, os, time, traceback, websockets, urllib.request, urllib.parse
import aiohttp
from datetime import datetime

CHROME_WS_BASE = "ws://localhost:9222"
AVE_API_KEY = "uHxe2IxOYEx3vHNpUpPtVDJVd2UTPycHLimZkAIpyMxkGS9GE84tf05VU96Uwgdm"
AVE_API_BASE = "https://prod.ave-api.com"
SCANNED_FILE = "/root/.openclaw/workspace/scanned_tokens.json"

# 筛选条件
MIN_MCAP = 10000        # $10K
MAX_MCAP = 5000000      # $5M
MIN_LIQUIDITY = 4000    # $5K
MIN_HOLDERS = 50
MIN_CHANGE_24H = 100     # 50%
MIN_VOL_MCAP_RATIO = 0.3  # 30%
MAX_BUNDLER_RATE = 0.5  # 50%

# --- 格式化辅助函数 ---
def fmt_num(n):
    if n is None: return "N/A"
    n = float(n)
    if n >= 1e9: return f"${n/1e9:.2f}B"
    if n >= 1e6: return f"${n/1e6:.2f}M"
    if n >= 1e3: return f"${n/1e3:.1f}K"
    if n >= 1: return f"${n:.2f}"
    if n >= 0.01: return f"${n:.4f}"
    return f"${n:.10f}".rstrip('0')

def fmt_pct(p):
    if p is None: return "N/A"
    return f"{'+' if float(p)>=0 else ''}{float(p):.2f}%"

def fmt_price(p):
    if p is None: return "N/A"
    p = float(p)
    if p >= 1: return f"${p:.4f}"
    if p >= 0.001: return f"${p:.8f}".rstrip('0')
    return f"${p:.10f}".rstrip('0')

def fmt_holders(h):
    if h is None: return "N/A"
    h = int(h)
    if h >= 1e6: return f"{h/1e6:.1f}M"
    if h >= 1e3: return f"{h:,}"
    return str(h)

def load_scanned():
    if os.path.exists(SCANNED_FILE):
        try:
            with open(SCANNED_FILE, 'r') as f:
                data = json.load(f)
                # 清理超过24小时的记录
                now = time.time()
                cleaned = {k: v for k, v in data.items() if now - v < 86400}
                return cleaned
        except:
            return {}
    return {}

def save_scanned(scanned):
    with open(SCANNED_FILE, 'w') as f:
        json.dump(scanned, f)

async def get_page_id():
    try:
        resp = urllib.request.urlopen("http://localhost:9222/json/list")
        tabs = json.loads(resp.read())
        for t in tabs:
            if t.get('type') == 'page' and 'gmgn' in t.get('url', ''):
                return t['id']
        if tabs:
            return tabs[0]['id']
    except Exception as e:
        print(f"Error getting page ID: {e}", file=sys.stderr)
    return None

async def chrome_fetch(ws, url):
    js = f"fetch('{url}').then(r=>r.text())"
    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {'expression': js, 'awaitPromise': True}}))
    resp = await ws.recv()
    return json.loads(resp).get('result', {}).get('result', {}).get('value', '')

async def scan_gmgn(ws, chain):
    """从 gmgn.ai rank 接口扫描热门代币"""
    tokens = []
    # 1h 交易最活跃
    url = f"https://gmgn.ai/defi/quotation/v1/rank/{chain}/swaps/1h?limit=50"
    raw = await chrome_fetch(ws, url)
    try:
        data = json.loads(raw)
        if data.get('code') == 0 and data.get('data', {}).get('rank'):
            for t in data['data']['rank']:
                tokens.append({
                    'source': 'gmgn_rank',
                    'chain': chain,
                    'address': t.get('address', ''),
                    'name': t.get('name', 'N/A'),
                    'symbol': t.get('symbol', 'N/A'),
                    'price': float(t['price']) if t.get('price') else None,
                    'market_cap': float(t['market_cap']) if t.get('market_cap') else None,
                    'liquidity': float(t['liquidity']) if t.get('liquidity') else None,
                    'volume_24h': float(t['volume']) if t.get('volume') else None,
                    'change_24h': float(t['price_change_percent']) if t.get('price_change_percent') else None,
                    'change_1h': float(t['price_change_percent1h']) if t.get('price_change_percent1h') else None,
                    'holder_count': int(t['holder_count']) if t.get('holder_count') else None,
                    'bundler_rate': float(t['bundler_rate']) if t.get('bundler_rate') else 0,
                    'is_honeypot': t.get('is_honeypot', False),
                    'top_10_holder_rate': float(t['top_10_holder_rate']) if t.get('top_10_holder_rate') else 0,
                    'swaps': int(t['swaps']) if t.get('swaps') else 0,
                    'buys': int(t['buys']) if t.get('buys') else 0,
                    'sells': int(t['sells']) if t.get('sells') else 0,
                    'twitter': t.get('twitter_username', ''),
                    'website': t.get('website', ''),
                    'launchpad': t.get('launchpad_platform', ''),
                    'open_timestamp': t.get('open_timestamp', 0),
                    'buy_tax': 0,
                    'sell_tax': 0,
                })
    except Exception as e:
        print(f"Error scanning gmgn rank {chain}: {e}", file=sys.stderr)
    
    # 新交易对
    url2 = f"https://gmgn.ai/defi/quotation/v1/pairs/{chain}/new_pairs?limit=30"
    raw2 = await chrome_fetch(ws, url2)
    try:
        data2 = json.loads(raw2)
        if data2.get('code') == 0 and data2.get('data', {}).get('pairs'):
            for p in data2['data']['pairs']:
                base = p.get('base_token_info', {})
                if not base:
                    continue
                tokens.append({
                    'source': 'gmgn_new',
                    'chain': chain,
                    'address': base.get('address', ''),
                    'name': base.get('name', 'N/A'),
                    'symbol': base.get('symbol', 'N/A'),
                    'price': None,  # new_pairs 通常没有价格
                    'market_cap': None,
                    'liquidity': float(p['initial_liquidity']) if p.get('initial_liquidity') else None,
                    'volume_24h': None,
                    'change_24h': None,
                    'change_1h': None,
                    'holder_count': int(base['holder_count']) if base.get('holder_count') else None,
                    'bundler_rate': 0,
                    'is_honeypot': base.get('is_honeypot', False),
                    'top_10_holder_rate': float(base['top_10_holder_rate']) if base.get('top_10_holder_rate') else 0,
                    'swaps': 0,
                    'buys': 0,
                    'sells': 0,
                    'twitter': base.get('social_links', {}).get('twitter_username', '') if isinstance(base.get('social_links'), dict) else '',
                    'website': base.get('social_links', {}).get('website', '') if isinstance(base.get('social_links'), dict) else '',
                    'launchpad': p.get('launchpad_platform', ''),
                    'open_timestamp': p.get('open_timestamp', 0),
                    'buy_tax': 0,
                    'sell_tax': 0,
                })
    except Exception as e:
        print(f"Error scanning gmgn new_pairs {chain}: {e}", file=sys.stderr)
    
    return tokens

async def scan_ave(chain):
    """从 Ave.ai trending 接口扫描热门代币"""
    tokens = []
    ave_chain = 'solana' if chain == 'sol' else chain
    url = f"{AVE_API_BASE}/v2/tokens/trending?chain={ave_chain}&page_size=50"
    headers = {"X-API-KEY": AVE_API_KEY}
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(url, headers=headers, timeout=10) as response:
                response.raise_for_status()
                data = await response.json()
                if data and data.get('status') == 1 and data.get('data', {}).get('tokens'):
                    for t in data['data']['tokens']:
                        tokens.append({
                            'source': 'ave_trending',
                            'chain': chain,
                            'address': t.get('token', ''),
                            'name': t.get('name', 'N/A'),
                            'symbol': t.get('symbol', 'N/A'),
                            'price': float(t['current_price_usd']) if t.get('current_price_usd') else None,
                            'market_cap': float(t['market_cap']) if t.get('market_cap') else None,
                            'liquidity': float(t['tvl']) if t.get('tvl') else None,
                            'volume_24h': float(t['token_tx_volume_usd_24h']) if t.get('token_tx_volume_usd_24h') else None,
                            'change_24h': float(t['token_price_change_24h']) if t.get('token_price_change_24h') else None,
                            'change_1h': float(t['token_price_change_1h']) if t.get('token_price_change_1h') else None,
                            'holder_count': int(t['holders']) if t.get('holders') else None,
                            'bundler_rate': 0,  # Ave 不提供 bundler_rate
                            'is_honeypot': t.get('is_honeypot', False),
                            'top_10_holder_rate': 0,
                            'swaps': int(t.get('token_buy_tx_count_5m', 0) or 0),
                            'buys': int(t.get('token_buy_tx_count_5m', 0) or 0),
                            'sells': int(t.get('token_sell_tx_count_5m', 0) or 0),
                            'twitter': '',
                            'website': '',
                            'launchpad': t.get('issue_platform', ''),
                            'open_timestamp': t.get('created_at', 0),
                            'buy_tax': 0,
                            'sell_tax': 0,
                        })
    except Exception as e:
        print(f"Error scanning Ave trending {chain}: {e}", file=sys.stderr)
    return tokens

def filter_token(t):
    """筛选符合条件的代币"""
    # 蜜罐排除
    if t.get('is_honeypot'):
        return False, "蜜罐"
    
    mc = t.get('market_cap')
    liq = t.get('liquidity')
    holders = t.get('holder_count')
    change_24h = t.get('change_24h')
    vol = t.get('volume_24h')
    bundler = t.get('bundler_rate', 0)
    
    # 市值范围
    if mc is None or mc < MIN_MCAP or mc > MAX_MCAP:
        return False, f"市值不符: {mc}"
    
    # 流动性
    if liq is None or liq < MIN_LIQUIDITY:
        return False, f"流动性不足: {liq}"
    
    # 持有者
    if holders is None or holders < MIN_HOLDERS:
        return False, f"持有者不足: {holders}"
    
    # 24h 涨幅
    if change_24h is None or change_24h < MIN_CHANGE_24H:
        return False, f"涨幅不足: {change_24h}"
    
    # 交易量/市值比
    if vol and mc and mc > 0:
        ratio = vol / mc
        if ratio < MIN_VOL_MCAP_RATIO:
            return False, f"交易量/市值比不足: {ratio:.2f}"
    
    # Bundler 比例
    if bundler > MAX_BUNDLER_RATE:
        return False, f"Bundler过高: {bundler}"
    
    return True, "通过"

def score_token(t):
    """给代币打分 1-10"""
    score = 5
    risks = []
    
    mc = t.get('market_cap', 0) or 0
    liq = t.get('liquidity', 0) or 0
    holders = t.get('holder_count', 0) or 0
    change_24h = t.get('change_24h', 0) or 0
    vol = t.get('volume_24h', 0) or 0
    bundler = t.get('bundler_rate', 0) or 0
    top10 = t.get('top_10_holder_rate', 0) or 0
    buy_tax = float(t.get('buy_tax', 0) or 0)
    sell_tax = float(t.get('sell_tax', 0) or 0)
    
    # 流动性评分
    if liq > 500000: score += 1
    elif liq < 20000:
        score -= 1
        risks.append("流动性偏低")
    
    # 市值评分
    if 100000 < mc < 2000000: score += 1
    elif mc > 5000000: score -= 1
    
    # 持有者评分
    if holders > 5000: score += 1
    elif holders < 200:
        score -= 1
        risks.append("持有者较少")
    
    # 涨幅评分
    if change_24h > 500: risks.append(f"24h涨{change_24h:.0f}%,注意回调")
    elif change_24h > 100: score += 1
    
    # Top10 集中度
    if top10 > 0.5:
        score -= 1
        risks.append(f"Top10集中度{top10*100:.1f}%")
    
    # Bundler
    if bundler > 0.3:
        score -= 1
        risks.append(f"Bundler比例{bundler*100:.1f}%")
    
    # Tax
    if buy_tax > 5 or sell_tax > 5:
        score -= 1
        risks.append(f"税率偏高(买{buy_tax}%/卖{sell_tax}%)")
    
    # 有社交媒体加分
    if t.get('twitter'): score += 1
    if t.get('website'): score += 1
    
    score = max(1, min(10, score))
    if len(risks) >= 3 or score <= 3:
        risk_level = "🔴 High"
    elif len(risks) >= 1 or score <= 5:
        risk_level = "🟡 Medium"
    else:
        risk_level = "🟢 Low"
    
    conviction = score # 暂时将 conviction 设置为和 score 一样,后续可以细化

    return score, risk_level, risks, conviction

def generate_why_alpha_v2(t, score, risk_level, risks, conviction, narrative_vibe):
    """根据代币数据生成Why Alpha分析 (精简版,融入叙事)"""
    name = t.get('name', '该代币')
    symbol = t.get('symbol', 'TOKEN')
    mc = t.get('market_cap', 0) or 0
    liq = t.get('liquidity', 0) or 0
    change_24h = t.get('change_24h', 0) or 0
    vol_24h = t.get('volume_24h', 0) or 0
    holders = t.get('holder_count', 0) or 0
    
    alpha_analysis = []

    # 1. 总体评价基于得分和风险
    alpha_analysis.append(f"{name}(${symbol})")
    if score >= 7:
        alpha_analysis.append(f"得分{score}/10,潜力强劲。")
    elif score >= 5:
        alpha_analysis.append(f"得分{score}/10,值得关注。")
    else:
        alpha_analysis.append(f"得分{score}/10,波动大,需谨慎。")

    # 2. 市场表现和流动性分析
    if change_24h > 100:
        alpha_analysis.append(f"24h暴涨{int(change_24h)}%,短期爆发力强。")
        if mc > 0 and vol_24h > mc * 5:
            alpha_analysis.append(f"交易量是市值{int(vol_24h/mc)}倍,市场关注度极高。")
    
    if liq < 10000:
        alpha_analysis.append(f"流动性{fmt_num(liq)}极低,存在巨大滑点风险。")
    elif liq < 50000:
        alpha_analysis.append(f"流动性{fmt_num(liq)}偏低,交易时需警惕。")
    
    if holders < 200:
        alpha_analysis.append(f"持有者少({holders}),存在巨鲸控盘风险。")

    # 3. 融入叙事分析
    if "动物币" in narrative_vibe:
        alpha_analysis.append("作为动物币,其社区共识和FOMO情绪是关键。")
    elif "政治名人币" in narrative_vibe:
        alpha_analysis.append("政治概念币受事件驱动影响大,波动性强。")
    elif "AI概念" in narrative_vibe:
        alpha_analysis.append("AI概念热度高,技术突破或合作可能带来机遇。")
    
    # 4. 关键风险点
    if "蜜罐" in risks:
        alpha_analysis.append("警惕蜜罐陷阱。")
    if any("Bundler" in r for r in risks):
        alpha_analysis.append("Bundler活动频繁,需警惕。")
    if any("税率偏高" in r for r in risks):
        alpha_analysis.append("交易税率较高,影响收益。")

    # 5. 总结性建议
    if score >= 7:
        alpha_analysis.append("综合看潜力强,但仍需密切关注市场。")
    elif score >= 5:
        alpha_analysis.append("作为新兴代币,值得追踪。")
    else:
        alpha_analysis.append("高风险,建议谨慎。")

    final_alpha = " ".join(alpha_analysis)
    if len(final_alpha) > 250: # 限制长度
        final_alpha = final_alpha[:247] + "..."
    return final_alpha.strip()

def generate_narrative_vibe(t):
    """根据代币信息生成 Narrative Vibe"""
    name = t.get('name', '')
    symbol = t.get('symbol', '')
    chain_name = t.get('chain', '').upper()

    vibe = []
    vibe.append(f"{chain_name} 生态")

    if "dog" in name.lower() or "shib" in name.lower() or "floki" in name.lower():
        vibe.append("动物币")
    elif "trump" in name.lower() or "biden" in name.lower() or "elon" in name.lower():
        vibe.append("政治名人币")
    elif "pepe" in name.lower() or "frog" in name.lower():
        vibe.append("青蛙币")
    elif "ai" in name.lower() or "gpt" in name.lower():
        vibe.append("AI概念")
    elif "game" in name.lower() or "play" in name.lower():
        vibe.append("GameFi")
    elif "meta" in name.lower():
        vibe.append("元宇宙")
    else:
        vibe.append("Memecoin")
    
    return ", ".join(vibe)


def format_token_data_for_agent(t, score, risk_level, risks, conviction):
    """构建推送消息,完全符合用户模板,由脚本直接输出"""
    chain = t['chain'].upper()
    mc = t.get('market_cap')
    liq = t.get('liquidity')
    vol = t.get('volume_24h')
    change_24h = t.get('change_24h')
    change_1h = t.get('change_1h')
    holders = t.get('holder_count')

    # 来源标签
    source_tag = ""
    if t.get('source') == 'gmgn_rank':
        source_tag = "📊 gmgn热门"
    elif t.get('source') == 'gmgn_new':
        source_tag = "🆕 gmgn新币"
    elif t.get('source') == 'ave_trending':
        source_tag = "🔥 Ave热门"

    vol_mc = ""
    if vol and mc and mc > 0:
        ratio = vol / mc * 100
        vol_mc = f" ({ratio:.0f}% of MC)"

    # 创建时间
    age = ""
    if t.get('open_timestamp') and t['open_timestamp'] > 0:
        if len(str(t['open_timestamp'])) == 13:  # if ms
            open_ts_s = t['open_timestamp'] / 1000
        else:  # if s
            open_ts_s = t['open_timestamp']

        age_sec = time.time() - open_ts_s
        if age_sec < 60:
            age = f" (创建 {int(age_sec)} 秒前)"
        elif age_sec < 3600:
            age = f" (创建 {int(age_sec / 60)} 分钟前)"
        elif age_sec < 86400:
            age = f" (创建 {int(age_sec / 3600)} 小时前)"
        else:
            age = f" (创建 {int(age_sec / 86400)} 天前)"

    # 推荐动作 (严格按用户指定)
    action = ""
    if score >= 8: # 恢复为8分重点关注
        action = "☢️ 重点关注"
    elif score >= 5: # 调整为5分可以关注
        action = "👀 可以关注"
    else:
        action = "⚠️ 谨慎观望"

    # Generate Narrative Vibe content early
    narrative_vibe_content = generate_narrative_vibe(t)

    # --- 构建 Why Alpha ---
    why_alpha_content = generate_why_alpha_v2(t, score, risk_level, risks, conviction, narrative_vibe_content)
    risk_text = risks[0] if risks else "暂无明显风险"

    # --- 构建最终消息 ---
    message_parts = []
    # New header format
    message_parts.append(f"🔔 发现潜力 Meme 代币!{source_tag} 🔍 {t['name']} (${t['symbol']}) | {chain}{age}")
    message_parts.append(f"CA: {t['address']}")  # CA独立成行

    message_parts.append("")  # 空行作为分隔

    # 表格部分
    message_parts.append("| 指标 | 数值 |")
    message_parts.append("| ---------- | -------------------- |") # 使用模板的更宽分隔
    message_parts.append(f"| 💰 价格 | {fmt_price(t.get('price'))} |")
    message_parts.append(f"| 📊 市值 | {fmt_num(mc)} |")
    message_parts.append(f"| 💧 流动性 | {fmt_num(liq)} |")
    message_parts.append(f"| 📈 24h | {fmt_pct(change_24h)} |")
    message_parts.append(f"| 👥 Holders | {fmt_holders(holders)} |")
    message_parts.append(f"| 📦 24h Vol | {fmt_num(vol)}{vol_mc} |") # 恢复市值百分比
    message_parts.append(f"| ⏱️ 1h | {fmt_pct(change_1h)} |")
    message_parts.append("")  # 显式添加一个空行来终止表格格式

    # 非表格部分
    message_parts.append(f"• Early Score: {score}/10")
    message_parts.append(f"• Risk: {risk_level}")
    message_parts.append(f"• Action: {action}")

    if risks:  # 如果有具体的风险,才显示第一个风险点
        message_parts.append(f"• ⚠️ 风险: {risks[0]}")

    # 确保 Website 格式匹配模板:[链接文字](链接地址)
    if t.get('twitter'):
        tw = t['twitter']
        if not tw.startswith('http'): tw = f"https://x.com/{tw}"
        message_parts.append(f"🐦 Twitter: [{tw}]({tw})")  # Markdown 链接

    if t.get('website'):  # 添加 Website 信息
        ws = t['website']
        if not ws.startswith('http'): ws = f"https://{ws}"
        message_parts.append(f"🌐 Website: [{ws}]({ws})") # 链接文字和地址相同,符合新的理解

    if t.get('launchpad'):
        message_parts.append(f"🚀 平台: {t['launchpad']}")

    message_parts.append(f"💡 Why Alpha: {why_alpha_content}")
    message_parts.append(f"• Narrative Vibe: {narrative_vibe_content}") # 从模板恢复 Narrative Vibe
    message_parts.append(f"• ⚠️ 风险提示: {risk_text}") # 从模板恢复风险提示,并匹配格式

    return "\n".join(message_parts)


async def main():
    print(f"[{datetime.now().strftime('%H:%M:%S')}] Meme Scanner 启动...", file=sys.stderr)
    
    scanned = load_scanned()
    
    page_id = await get_page_id()
    if not page_id:
        print("❌ Chrome 未连接", file=sys.stderr)
        sys.exit(1)
    
    uri = f"{CHROME_WS_BASE}/devtools/page/{page_id}"
    
    all_tokens = []
    
    async with websockets.connect(uri) as ws:
        current_url = await ws.send(json.dumps({'id': 0, 'method': 'Runtime.evaluate', 'params': {'expression': 'window.location.href', 'awaitPromise': False}}))
        resp = await ws.recv()
        current_url = json.loads(resp).get('result', {}).get('result', {}).get('value', '')
        if "gmgn.ai" not in current_url:
            await ws.send(json.dumps({'id': 0, 'method': 'Page.navigate', 'params': {'url': 'https://gmgn.ai/'}}))
            await ws.recv()
            await asyncio.sleep(2)
        
        for chain in ['sol', 'bsc']:
            tokens = await scan_gmgn(ws, chain)
            all_tokens.extend(tokens)
            print(f"[{datetime.now().strftime('%H:%M:%S')}] gmgn {chain}: 获取 {len(tokens)} 个代币", file=sys.stderr)
    
    for chain in ['sol', 'bsc']:
        tokens = await scan_ave(chain)
        all_tokens.extend(tokens)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] Ave {chain}: 获取 {len(tokens)} 个代币", file=sys.stderr)
    
    seen_addresses = set()
    unique_tokens = []
    for t in all_tokens:
        addr = t['address'].lower()
        if addr not in seen_addresses:
            seen_addresses.add(addr)
            unique_tokens.append(t)
    
    print(f"[{datetime.now().strftime('%H:%M:%S')}] 去重后共 {len(unique_tokens)} 个代币", file=sys.stderr)
    
    qualified = []
    for t in unique_tokens:
        addr = t['address'].lower()
        if addr in scanned:
            continue
        
        passed, reason = filter_token(t)
        if passed:
            # 记录为已扫描,避免下次重复处理,无论最终是否推送消息
            scanned[addr] = time.time() 
            
            # 添加评分和其他临时数据,用于后续排序和消息生成
            score_val, risk_level_val, risks_val, conviction_val = score_token(t)
            t['__score'] = score_val
            t['__risk_level'] = risk_level_val
            t['__risks'] = risks_val
            t['__conviction'] = conviction_val
            qualified.append(t)
    
    print(f"[{datetime.now().strftime('%H:%M:%S')}] 筛选通过 {len(qualified)} 个代币", file=sys.stderr)
    
    qualified.sort(key=lambda t: t['__score'], reverse=True)
    
    # 保存已扫描记录
    save_scanned(scanned)
    
    # 生成所有符合条件的代币消息(Early Score >= 8)
    messages_to_send = []
    for t in qualified:
        if t['__score'] >= 8:  # 只推送 Early Score >= 8 的代币
            msg = format_token_data_for_agent(t, t['__score'], t['__risk_level'], t['__risks'], t['__conviction'])
            messages_to_send.append(msg)
    
    # 收集所有符合条件的代币消息
    if messages_to_send:
        # 将所有消息打包成JSON数组输出到stdout,确保不转义Unicode
        print(json.dumps(messages_to_send, ensure_ascii=False))
    else:
        print("[]") # 没有消息时输出空数组

if __name__ == '__main__':
    try:
        asyncio.run(main())
    except Exception as e:
        print(f"扫链脚本出错: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        sys.exit(1)
```

### scripts/meme_scanner_v2.py

```python
#!/usr/bin/env python3
"""
Meme Scanner v2 - 基于 GMGN 官方 API 的扫链工具
完全使用 GMGN API,通过浏览器 CDP 绕过 Cloudflare
"""

import json, asyncio, sys, os, time, websockets, urllib.request
from datetime import datetime

CHROME_WS_BASE = "ws://localhost:9222"
SCANNED_FILE = "/root/.openclaw/workspace/scanned_tokens.json"

# GMGN API 配置
GMGN_BASE = "https://gmgn.ai"
DEVICE_ID = "bf3bd459-9cc5-46f0-95bf-8297b8a58c72"
FP_DID = "c44ca81f8d7dabb1d0dc62c33c0ee26d"
CLIENT_ID = "gmgn_web_20260304-11376-fc51c8a"
FROM_APP = "gmgn"
APP_VER = "20260304-11376-fc51c8a"
TZ_NAME = "Asia/Shanghai"
TZ_OFFSET = "28800"
APP_LANG = "zh-CN"
OS = "web"
WORKER = "0"

# 筛选条件(测试用,非常宽松)
MIN_MCAP = 5000         # $5K
MAX_MCAP = 10000000     # $10M
MIN_LIQUIDITY = 1000    # $1K
MIN_HOLDERS = 10        # 10人
MIN_CHANGE_24H = 0      # 0%
MIN_VOL_MCAP_RATIO = 0.1  # 10%
MAX_BUNDLER_RATE = 0.9  # 90%
MIN_EARLY_SCORE = 4     # 4分

def get_common_params():
    return f"device_id={DEVICE_ID}&fp_did={FP_DID}&client_id={CLIENT_ID}&from_app={FROM_APP}&app_ver={APP_VER}&tz_name={TZ_NAME}&tz_offset={TZ_OFFSET}&app_lang={APP_LANG}&os={OS}&worker={WORKER}"

def fmt_num(n):
    if n is None: return "N/A"
    n = float(n)
    if n >= 1e9: return f"${n/1e9:.2f}B"
    if n >= 1e6: return f"${n/1e6:.2f}M"
    if n >= 1e3: return f"${n/1e3:.1f}K"
    return f"${n:.2f}"

def fmt_price(p):
    if p is None: return "N/A"
    p = float(p)
    if p >= 1: return f"${p:.4f}"
    if p >= 0.001: return f"${p:.8f}".rstrip('0')
    return f"${p:.12f}".rstrip('0')

def load_scanned():
    if os.path.exists(SCANNED_FILE):
        try:
            with open(SCANNED_FILE, 'r') as f:
                data = json.load(f)
                now = time.time()
                cleaned = {k: v for k, v in data.items() if now - v < 86400}
                return cleaned
        except:
            return {}
    return {}

def save_scanned(scanned):
    with open(SCANNED_FILE, 'w') as f:
        json.dump(scanned, f)

async def get_page_id():
    try:
        resp = urllib.request.urlopen("http://localhost:9222/json/list")
        tabs = json.loads(resp.read())
        for t in tabs:
            if t.get('type') == 'page' and 'gmgn.ai' in t.get('url', ''):
                return t['id']
        for t in tabs:
            if t.get('type') == 'page':
                return t['id']
    except Exception as e:
        print(f"Error getting page ID: {e}", file=sys.stderr)
    return None

async def chrome_fetch(ws, url):
    js = f"fetch('{url}').then(r=>r.json())"
    await ws.send(json.dumps({'id':1,'method':'Runtime.evaluate','params':{'expression':js,'awaitPromise':True}}))
    resp = await ws.recv()
    result = json.loads(resp).get('result',{}).get('result',{})
    if result.get('type') == 'object':
        obj_id = result.get('objectId')
        if obj_id:
            await ws.send(json.dumps({'id':2,'method':'Runtime.callFunctionOn','params':{'objectId':obj_id,'functionDeclaration':'function(){return JSON.stringify(this)}'}}))
            resp2 = await ws.recv()
            json_str = json.loads(resp2).get('result',{}).get('result',{}).get('value','{}')
            return json.loads(json_str)
    return {}

async def scan_gmgn_rank(ws, chain):
    """使用 GMGN rank API 扫描热门代币"""
    params = get_common_params()
    tokens = []
    
    # 1. 扫描 1小时交易最活跃
    url = f"{GMGN_BASE}/defi/quotation/v1/rank/{chain}/swaps/1h?{params}&limit=50&orderby=swaps&direction=desc"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        rank_list = data.get('data', {}).get('rank', [])
        for item in rank_list:
            # rank API 返回的数据已经包含大部分信息
            token = {
                'address': item.get('address'),
                'chain': chain,
                'symbol': item.get('symbol'),
                'price': float(item.get('price', 0)),
                'market_cap': float(item.get('market_cap', 0)),
                'liquidity': float(item.get('liquidity', 0)),
                'volume_24h': float(item.get('volume', 0)),
                'holder_count': item.get('holder_count', 0),
                'is_honeypot': item.get('is_honeypot', 0) == 1,
                'buy_tax': float(item.get('buy_tax', 0) or 0),
                'sell_tax': float(item.get('sell_tax', 0) or 0),
                'top_10_holder_rate': float(item.get('top_10_holder_rate', 0)),
                'twitter': item.get('twitter_username'),
                'website': item.get('website'),
                'creation_timestamp': item.get('creation_timestamp'),
                # 计算涨跌幅
                'change_1h': item.get('price_change_percent1h', 0) * 100 if item.get('price_change_percent1h') else 0,
            }
            tokens.append(token)
    
    # 2. 扫描 24小时涨幅榜
    url = f"{GMGN_BASE}/defi/quotation/v1/rank/{chain}/gainers/24h?{params}&limit=50&orderby=price_change_percent&direction=desc"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        rank_list = data.get('data', {}).get('rank', [])
        for item in rank_list:
            token = {
                'address': item.get('address'),
                'chain': chain,
                'symbol': item.get('symbol'),
                'price': float(item.get('price', 0)),
                'market_cap': float(item.get('market_cap', 0)),
                'liquidity': float(item.get('liquidity', 0)),
                'volume_24h': float(item.get('volume', 0)),
                'holder_count': item.get('holder_count', 0),
                'is_honeypot': item.get('is_honeypot', 0) == 1,
                'buy_tax': float(item.get('buy_tax', 0) or 0),
                'sell_tax': float(item.get('sell_tax', 0) or 0),
                'top_10_holder_rate': float(item.get('top_10_holder_rate', 0)),
                'twitter': item.get('twitter_username'),
                'website': item.get('website'),
                'creation_timestamp': item.get('creation_timestamp'),
                'change_24h': item.get('price_change_percent', 0) * 100 if item.get('price_change_percent') else 0,
            }
            tokens.append(token)
    
    return tokens

async def fetch_token_details(ws, chain, address):
    """获取代币详细信息"""
    params = get_common_params()
    token = {}
    
    # 1. 基础信息
    url = f"{GMGN_BASE}/vas/api/v1/search_v3?{params}&chain={chain}&q={address}"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        coins = data.get('data', {}).get('coins', [])
        if coins:
            coin = coins[0]
            token['address'] = address
            token['chain'] = chain
            token['name'] = coin.get('name')
            token['symbol'] = coin.get('symbol')
            token['price'] = float(coin.get('price', 0))
            token['market_cap'] = float(coin.get('mcp', 0))
            token['liquidity'] = float(coin.get('liquidity', 0))
            token['volume_24h'] = float(coin.get('volume_24h', 0))
            token['holder_count'] = coin.get('holder_count', 0)
            
            # 计算24小时涨跌幅
            price_24h = coin.get('price_24h')
            if price_24h and float(price_24h) > 0:
                current_price = float(coin.get('price', 0))
                price_24h_float = float(price_24h)
                token['change_24h'] = ((current_price - price_24h_float) / price_24h_float) * 100
    
    # 2. 安全检测
    url = f"{GMGN_BASE}/api/v1/mutil_window_token_security_launchpad/{chain}/{address}?{params}"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        security = data.get('data', {}).get('security', {})
        token['is_honeypot'] = security.get('is_honeypot', False)
        token['buy_tax'] = float(security.get('buy_tax', 0) or 0)
        token['sell_tax'] = float(security.get('sell_tax', 0) or 0)
    
    # 3. 持有者统计
    url = f"{GMGN_BASE}/api/v1/token_stat/{chain}/{address}?{params}"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        stat = data.get('data', {})
        token['top_10_holder_rate'] = float(stat.get('top_10_holder_rate', 0))
        token['bundler_rate'] = float(stat.get('top_bundler_trader_percentage', 0))
    
    # 4. 社交链接
    url = f"{GMGN_BASE}/api/v1/mutil_window_token_link_rug_vote/{chain}/{address}?{params}"
    data = await chrome_fetch(ws, url)
    if data.get('code') == 0:
        link = data.get('data', {}).get('link', {})
        token['twitter'] = link.get('twitter_username')
        token['website'] = link.get('website')
    
    return token

def calculate_early_score(token):
    """计算早期得分"""
    score = 5
    mc = token.get('market_cap', 0) or 0
    liq = token.get('liquidity', 0) or 0
    holders = token.get('holder_count', 0) or 0
    vol = token.get('volume_24h', 0) or 0
    change_24h = token.get('change_24h', 0) or 0
    bundler = token.get('bundler_rate', 0) or 0
    
    # 流动性评分
    if liq > 100000: score += 2
    elif liq > 50000: score += 1
    elif liq < 10000: score -= 1
    
    # 市值评分
    if 100000 < mc < 2000000: score += 2
    elif mc < 50000: score += 1
    
    # 持有者评分
    if holders > 1000: score += 1
    elif holders < 100: score -= 1
    
    # 交易量评分
    if mc > 0:
        vol_ratio = vol / mc
        if vol_ratio > 1: score += 2
        elif vol_ratio > 0.5: score += 1
    
    # 涨幅评分
    if change_24h > 500: score += 2
    elif change_24h > 200: score += 1
    
    # Bundler 惩罚
    if bundler > 0.5: score -= 2
    elif bundler > 0.3: score -= 1
    
    return max(1, min(10, score))

def filter_token(token):
    """筛选代币"""
    mc = token.get('market_cap', 0) or 0
    liq = token.get('liquidity', 0) or 0
    holders = token.get('holder_count', 0) or 0
    change_24h = token.get('change_24h', 0) or 0
    bundler = token.get('bundler_rate', 0) or 0
    vol = token.get('volume_24h', 0) or 0
    
    # 基础筛选
    if token.get('is_honeypot'): return False
    if not (MIN_MCAP <= mc <= MAX_MCAP): return False
    if liq < MIN_LIQUIDITY: return False
    if holders < MIN_HOLDERS: return False
    if change_24h < MIN_CHANGE_24H: return False
    if bundler > MAX_BUNDLER_RATE: return False
    
    # 交易量/市值比
    if mc > 0:
        vol_ratio = vol / mc
        if vol_ratio < MIN_VOL_MCAP_RATIO: return False
    
    # 早期得分
    score = calculate_early_score(token)
    if score < MIN_EARLY_SCORE: return False
    
    return True

def format_message(token):
    """格式化消息"""
    chain = token['chain'].upper()
    address = token['address']
    score = calculate_early_score(token)
    
    # 风险等级
    if score >= 8:
        risk_level = "🟢 Low"
        action = "☢️ 重点关注"
    elif score >= 6:
        risk_level = "🟡 Medium"
        action = "👀 可以关注"
    else:
        risk_level = "🔴 High"
        action = "⚠️ 谨慎观望"
    
    # 计算创建时间
    age_str = "未知"
    creation_timestamp = token.get('creation_timestamp')
    if creation_timestamp:
        age_seconds = int(time.time() - creation_timestamp)
        if age_seconds < 3600:
            age_str = f"{age_seconds // 60} 分钟前"
        elif age_seconds < 86400:
            age_str = f"{age_seconds // 3600} 小时前"
        else:
            age_str = f"{age_seconds // 86400} 天前"
    
    msg = f"🔔 发现潜力 Meme 代币!📊 gmgn热门\n"
    msg += f"🔍 {token.get('name', 'Unknown')} (${token.get('symbol', 'N/A')}) | {chain} (创建 {age_str})\n"
    msg += f"CA: {address}\n\n"
    msg += f"| 指标 | 数值 |\n"
    msg += f"| ---------- | -------------------- |\n"
    msg += f"| 💰 价格 | {fmt_price(token.get('price'))} |\n"
    msg += f"| 📊 市值 | {fmt_num(token.get('market_cap'))} |\n"
    msg += f"| 💧 流动性 | {fmt_num(token.get('liquidity'))} |\n"
    
    # 涨跌幅
    change_24h = token.get('change_24h')
    if change_24h:
        msg += f"| 📈 24h | {'+' if change_24h >= 0 else ''}{change_24h:.2f}% |\n"
    
    msg += f"| 👥 Holders | {token.get('holder_count', 'N/A')} |\n"
    
    vol_mc_ratio = 0
    if token.get('volume_24h') and token.get('market_cap'):
        vol_mc_ratio = (token['volume_24h'] / token['market_cap']) * 100
    msg += f"| 📦 24h Vol | {fmt_num(token.get('volume_24h'))} ({vol_mc_ratio:.0f}% of MC) |\n"
    
    change_1h = token.get('change_1h')
    if change_1h:
        msg += f"| ⏱️ 1h | {'+' if change_1h >= 0 else ''}{change_1h:.2f}% |\n"
    
    msg += f"\n• Early Score: {score}/10\n"
    msg += f"• Risk: {risk_level}\n"
    msg += f"• Action: {action}\n"
    
    # 链接
    links = []
    if token.get('twitter'):
        twitter = token['twitter']
        if not twitter.startswith('http'):
            twitter = f"https://x.com/{twitter}"
        links.append(f"🐦 Twitter: {twitter}")
    if token.get('website'):
        links.append(f"🌐 Website: {token['website']}")
    
    if links:
        msg += "\n".join(links) + "\n"
    
    # GMGN 链接
    msg += f"🔗 [GMGN](https://gmgn.ai/{token['chain']}/token/{address})\n"
    
    # Why Alpha 分析
    why_alpha = generate_why_alpha(token, score, change_24h, change_1h)
    msg += f"💡 Why Alpha: {why_alpha}\n"
    
    # Narrative Vibe
    narrative = generate_narrative(token, chain)
    msg += f"• Narrative Vibe: {narrative}\n"
    
    # 风险提示
    risks = []
    if token.get('bundler_rate', 0) > 0.3:
        risks.append(f"Bundler比例{token['bundler_rate']*100:.0f}%")
    if token.get('top_10_holder_rate', 0) > 0.5:
        risks.append(f"Top10集中度{token['top_10_holder_rate']*100:.0f}%")
    
    liq = token.get('liquidity', 0) or 0
    if liq < 10000:
        risks.append("流动性偏低")
    
    buy_tax = token.get('buy_tax', 0) or 0
    sell_tax = token.get('sell_tax', 0) or 0
    if buy_tax > 5 or sell_tax > 5:
        risks.append(f"税率偏高(买{buy_tax:.0f}%/卖{sell_tax:.0f}%)")
    
    if risks:
        msg += f"• ⚠️ 风险提示: {', '.join(risks)}\n"
    else:
        msg += f"• ⚠️ 风险提示: 暂无明显风险\n"
    
    return msg

def generate_why_alpha(token, score, change_24h, change_1h):
    """生成 Why Alpha 分析"""
    name = token.get('name', '该代币')
    symbol = token.get('symbol', 'N/A')
    mc = token.get('market_cap', 0) or 0
    vol = token.get('volume_24h', 0) or 0
    
    analysis = f"{name}(${symbol}) 得分{score}/10,"
    
    if score >= 8:
        analysis += "潜力强劲。"
    elif score >= 6:
        analysis += "潜力中等。"
    else:
        analysis += "潜力较弱。"
    
    if change_24h and change_24h > 50:
        analysis += f" 24h暴涨{change_24h:.0f}%,短期爆发力强。"
    elif change_24h and change_24h > 0:
        analysis += f" 24h上涨{change_24h:.0f}%,表现稳健。"
    
    if mc > 0 and vol > 0:
        vol_ratio = vol / mc
        if vol_ratio > 10:
            analysis += f" 交易量是市值{vol_ratio:.0f}倍,市场关注度极高。"
        elif vol_ratio > 1:
            analysis += f" 交易量是市值{vol_ratio:.0f}倍,交易活跃。"
    
    analysis += " 综合看"
    if score >= 8:
        analysis += "潜力强,值得重点关注。"
    elif score >= 6:
        analysis += "有一定潜力,可以关注。"
    else:
        analysis += "风险较高,需谨慎。"
    
    return analysis

def generate_narrative(token, chain):
    """生成 Narrative Vibe"""
    narratives = [f"{chain.upper()} 生态"]
    
    name = token.get('name', '').lower()
    symbol = token.get('symbol', '').lower()
    
    # 根据名称判断叙事
    if any(x in name or x in symbol for x in ['trump', 'biden', 'musk', 'cz', 'elon']):
        narratives.append("政治名人币")
    elif any(x in name or x in symbol for x in ['dog', 'cat', 'pepe', 'frog', '狗', '猫', '青蛙']):
        narratives.append("动物币")
    elif any(x in name or x in symbol for x in ['ai', 'gpt', 'bot', 'agent']):
        narratives.append("AI概念")
    elif any(x in name or x in symbol for x in ['game', 'play', '游戏']):
        narratives.append("游戏")
    else:
        narratives.append("MEME币")
    
    return ", ".join(narratives)

async def main():
    scanned = load_scanned()
    page_id = await get_page_id()
    
    if not page_id:
        print("❌ 无法连接到 Chrome DevTools", file=sys.stderr)
        return
    
    ws_url = f"{CHROME_WS_BASE}/devtools/page/{page_id}"
    
    async with websockets.connect(ws_url) as ws:
        results = []
        
        # 扫描 SOL 和 BSC
        for chain in ['sol', 'bsc']:
            tokens = await scan_gmgn_rank(ws, chain)
            
            for token in tokens:
                address = token.get('address')
                if not address: continue
                
                # 跳过已扫描的
                if address in scanned: continue
                
                # 获取 name(rank API 没有返回)
                if not token.get('name'):
                    params = get_common_params()
                    url = f"{GMGN_BASE}/vas/api/v1/search_v3?{params}&chain={chain}&q={address}"
                    data = await chrome_fetch(ws, url)
                    if data.get('code') == 0:
                        coins = data.get('data', {}).get('coins', [])
                        if coins:
                            token['name'] = coins[0].get('name')
                
                # 获取 bundler 数据
                if 'bundler_rate' not in token:
                    params = get_common_params()
                    url = f"{GMGN_BASE}/api/v1/token_stat/{chain}/{address}?{params}"
                    data = await chrome_fetch(ws, url)
                    if data.get('code') == 0:
                        stat = data.get('data', {})
                        token['bundler_rate'] = float(stat.get('top_bundler_trader_percentage', 0))
                
                # 筛选
                if not filter_token(token): continue
                
                # 格式化消息
                message = format_message(token)
                results.append(message)
                
                # 标记为已扫描
                scanned[address] = time.time()
        
        # 保存已扫描记录
        save_scanned(scanned)
        
        # 输出结果
        if results:
            print(json.dumps(results, ensure_ascii=False))
        else:
            print("[]")

if __name__ == "__main__":
    asyncio.run(main())

```