diff --git a/openclaw-skill/opennews/SKILL.md b/openclaw-skill/opennews/SKILL.md index 959b4b4..ac5dce7 100644 --- a/openclaw-skill/opennews/SKILL.md +++ b/openclaw-skill/opennews/SKILL.md @@ -1,15 +1,15 @@ --- name: opennews -description: "Real-time crypto & financial news aggregator — 72+ data sources across 5 categories (News: Bloomberg, Reuters, FT, CNBC, CoinDesk, Twitter/X + 47 more; Listing: Binance, Coinbase, OKX + 6 more; OnChain: whale & KOL trades; Meme: social sentiment; Market: price/funding/liquidation alerts). AI-analyzed with impact score, trading signals, and bilingual summaries." +description: "Real-time crypto & financial news aggregator — 72+ data sources across 5 categories (News: Bloomberg, Reuters, FT, CNBC, CoinDesk, Twitter/X + 47 more; Listing: Binance, Coinbase, OKX + 6 more; OnChain: whale & KOL trades; Meme: social sentiment; Market: price/funding/liquidation alerts). AI-analyzed with impact score, trading signals, and bilingual summaries. **Free tools available without token**." user-invocable: true metadata: openclaw: requires: - env: - - OPENNEWS_TOKEN bins: - curl + optionalEnv: + - OPENNEWS_TOKEN primaryEnv: OPENNEWS_TOKEN emoji: "\U0001F4F0" install: @@ -21,7 +21,7 @@ metadata: - darwin - linux - win32 - version: 1.0.1 + version: 1.0.2 --- # OpenNews Crypto News Skill @@ -42,6 +42,86 @@ Real-time crypto & financial news aggregator powered by 6551.io — **72+ data s | **Meme** | 1 | Twitter meme coin social sentiment | | **Market** | 6 | Price Change, Funding Rate, Funding Rate Difference, Large Liquidation, Market Trends, OI Change | +## Free Tools (No Token Required) + +Two tools are available **without** an API token: + +### 1. Get News Categories + +Get all available news categories and subcategories. + +```bash +curl -s "https://ai.6551.io/open/free_categories" +``` + +### 2. Get Hot News + +Get hot news articles and trending tweets by category. + +```bash +curl -s "https://ai.6551.io/open/free_hot?category=macro" +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| category | string | Yes | Category key from free_categories (e.g., `macro`, `crypto`, `defi`) | +| subcategory | string | No | Subcategory key for filtered results | + +**Example categories:** +- `macro` - Macro economics and policy +- `crypto` - Cryptocurrency news +- `blockchain` - Blockchain technology +- `defi` - DeFi updates +- `nft` - NFT news +- `market` - Market analysis + +**Response structure:** +```json +{ + "success": true, + "category": "crypto", + "subcategory": "defi", + "news": { + "success": true, + "count": 10, + "items": [ + { + "id": 123, + "title": "DeFi Protocol Reaches $1B TVL", + "source": "CoinDesk", + "link": "https://...", + "score": 85, + "grade": "A", + "signal": "bullish", + "summary_zh": "...", + "summary_en": "...", + "coins": ["BTC", "ETH"], + "published_at": "2026-03-17T10:00:00Z" + } + ] + }, + "tweets": { + "success": true, + "count": 5, + "items": [ + { + "author": "Vitalik Buterin", + "handle": "VitalikButerin", + "content": "...", + "url": "https://...", + "metrics": { "likes": 1000, "retweets": 200, "replies": 50 }, + "posted_at": "2026-03-17T09:00:00Z", + "relevance": "high" + } + ] + } +} +``` + +### Get API Token for Full Access + +To access all 72+ sources with AI analysis, trading signals, and real-time WebSocket updates, get your API token at https://6551.io/mcp + ## Authentication All requests require the header: @@ -151,7 +231,26 @@ Important: You need to understand the user's query intent and perform word segme ## Common Workflows -### Quick Market Overview +### Free Tools (No Token Required) + +**Get all categories:** +```bash +curl -s "https://ai.6551.io/open/free_categories" +``` + +**Get hot crypto news:** +```bash +curl -s "https://ai.6551.io/open/free_hot?category=macro" +``` + +**Get DeFi subcategory news:** +```bash +curl -s "https://ai.6551.io/open/free_hot?category=macro&subcategory=defi" +``` + +### Premium Tools (Requires API Token) + +**Quick Market Overview:** ```bash curl -s -X POST "https://ai.6551.io/open/news_search" \ -H "Authorization: Bearer $OPENNEWS_TOKEN" \ @@ -169,6 +268,24 @@ curl -s -X POST "https://ai.6551.io/open/news_search" \ ## Notes -- Get your API token at https://6551.io/mcp +### Free vs. Premium Features + +**Free (no token required):** +- `get_news_categories` - Browse available news categories +- `get_hot_news` - Get hot news by category + +**Premium (requires API token from https://6551.io/mcp):** +- Full access to 72+ data sources +- AI analysis with impact scores (0-100), grades (A-F), and trading signals (long/short/neutral) +- Advanced search by keyword, coin, source, or engine type +- Real-time WebSocket news streaming +- Bilingual summaries (English & Chinese) +- Market anomaly signals (liquidations, funding rates, OI changes) + +### API Limits + - Rate limits apply; max 100 results per request - AI ratings may not be available on all articles (check `status == "done"`) +- **Free API data is cached and updated periodically** - not real-time +- If data is still being generated, a 503 response may be returned +- Get your API token at https://6551.io/mcp for full access diff --git a/src/opennews_mcp/api_client.py b/src/opennews_mcp/api_client.py index 1604d3f..c843776 100644 --- a/src/opennews_mcp/api_client.py +++ b/src/opennews_mcp/api_client.py @@ -15,6 +15,65 @@ logger = logging.getLogger(__name__) MAX_RETRIES = 2 +class FreeNewsAPIClient: + """Async HTTP client for free (no-auth) 6551 news API endpoints.""" + + def __init__(self, base_url: str = API_BASE_URL): + self.base_url = base_url.rstrip("/") + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) + return self._client + + async def _reset_client(self): + """Force close and recreate the HTTP client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def close(self): + await self._reset_client() + + async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: + """Execute an HTTP request with automatic retry on connection errors.""" + last_exc = None + for attempt in range(MAX_RETRIES + 1): + try: + client = await self._get_client() + resp = await client.request(method, url, **kwargs) + resp.raise_for_status() + return resp + except (httpx.ConnectError, httpx.RemoteProtocolError) as e: + last_exc = e + logger.warning( + "Connection error (attempt %d/%d): %s", + attempt + 1, MAX_RETRIES + 1, repr(e), + ) + await self._reset_client() + except httpx.HTTPStatusError: + raise + raise last_exc # type: ignore[misc] + + async def get_free_categories(self) -> dict: + """GET /open/free_categories — 获取所有新闻分类""" + resp = await self._request("GET", f"{self.base_url}/open/free_categories") + return resp.json() + + async def get_free_hot( + self, + category: str, + subcategory: str = "", + ) -> dict: + """GET /open/free_hot — 获取热点新闻和推文""" + params: dict = {"category": category} + if subcategory: + params["subcategory"] = subcategory + resp = await self._request("GET", f"{self.base_url}/open/free_hot", params=params) + return resp.json() + + class NewsAPIClient: """Async HTTP client for the 6551 news REST API.""" diff --git a/src/opennews_mcp/app.py b/src/opennews_mcp/app.py index c6e8518..56ae3c9 100644 --- a/src/opennews_mcp/app.py +++ b/src/opennews_mcp/app.py @@ -7,7 +7,8 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP -from opennews_mcp.api_client import NewsAPIClient, NewsWSClient +from opennews_mcp.api_client import FreeNewsAPIClient, NewsAPIClient, NewsWSClient +from opennews_mcp.config import HAS_TOKEN # Knowledge directory (project root / knowledge) KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent.parent / "knowledge" @@ -16,20 +17,25 @@ KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent.parent / "knowledge" @dataclass class AppContext: """Shared application state available to all tools via ctx.""" - api: NewsAPIClient - ws: NewsWSClient + free_api: FreeNewsAPIClient + api: NewsAPIClient | None = None + ws: NewsWSClient | None = None @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Manage the API client lifecycle.""" - api = NewsAPIClient() - ws = NewsWSClient() + free_api = FreeNewsAPIClient() + api = NewsAPIClient() if HAS_TOKEN else None + ws = NewsWSClient() if HAS_TOKEN else None try: - yield AppContext(api=api, ws=ws) + yield AppContext(free_api=free_api, api=api, ws=ws) finally: - await api.close() - await ws.close() + await free_api.close() + if api: + await api.close() + if ws: + await ws.close() # ---------- FastMCP instance ---------- diff --git a/src/opennews_mcp/config.py b/src/opennews_mcp/config.py index 47f0f47..65899ed 100644 --- a/src/opennews_mcp/config.py +++ b/src/opennews_mcp/config.py @@ -24,12 +24,24 @@ API_BASE_URL = os.environ.get("OPENNEWS_API_BASE") or _cfg.get("api_base_url", " WSS_URL = os.environ.get("OPENNEWS_WSS_URL") or _cfg.get("wss_url", "") API_TOKEN = os.environ.get("OPENNEWS_TOKEN") or _cfg.get("api_token", "") -# 检查 token 是否配置 -if not API_TOKEN: - raise ValueError( - "OPENNEWS_TOKEN 未配置。请前往 https://6551.io/mcp 申请 API Token," - "然后设置环境变量 OPENNEWS_TOKEN 或在 config.json 中配置 api_token。" - ) +# Token 状态检查(可选) +HAS_TOKEN = bool(API_TOKEN) + +TOKEN_REQUIRED_MSG = ( + "此工具需要 API Token 才能使用。\n" + "请前往 https://6551.io/mcp 免费申请 Token,\n" + "然后设置环境变量 OPENNEWS_TOKEN 或在 config.json 中填写 api_token。\n\n" + "This tool requires an API token. " + "Get your free token at https://6551.io/mcp, " + "then set OPENNEWS_TOKEN env var or add api_token to config.json." +) + + +def require_token() -> dict | None: + """检查是否配置了 token,未配置时返回错误信息。""" + if not HAS_TOKEN: + return {"success": False, "error": TOKEN_REQUIRED_MSG} + return None # ---------- Safety ---------- MAX_ROWS = int(os.environ.get("OPENNEWS_MAX_ROWS", 0) or _cfg.get("max_rows", 100)) diff --git a/src/opennews_mcp/tools/__init__.py b/src/opennews_mcp/tools/__init__.py index f4f0c2b..200352d 100644 --- a/src/opennews_mcp/tools/__init__.py +++ b/src/opennews_mcp/tools/__init__.py @@ -4,5 +4,6 @@ Each module is self-contained and atomic — no inter-tool dependencies. """ from opennews_mcp.tools import discovery # noqa: F401 +from opennews_mcp.tools import free # noqa: F401 from opennews_mcp.tools import news # noqa: F401 from opennews_mcp.tools import realtime # noqa: F401 diff --git a/src/opennews_mcp/tools/discovery.py b/src/opennews_mcp/tools/discovery.py index 624f24e..b53b29e 100644 --- a/src/opennews_mcp/tools/discovery.py +++ b/src/opennews_mcp/tools/discovery.py @@ -3,6 +3,7 @@ from mcp.server.fastmcp import Context from opennews_mcp.app import mcp +from opennews_mcp.config import require_token @mcp.tool() @@ -31,6 +32,8 @@ async def get_news_sources(ctx: Context) -> dict: Returns a tree structure with all engine types and their sub-categories. Use this first to discover what sources are available before searching. """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api try: @@ -72,6 +75,8 @@ async def list_news_types(ctx: Context) -> dict: Returns a flat list of news source codes that can be used with the newsType parameter in search_news. """ + if (err := require_token()): + return err # See get_news_sources for the full 72+ source catalog. api = ctx.request_context.lifespan_context.api diff --git a/src/opennews_mcp/tools/free.py b/src/opennews_mcp/tools/free.py new file mode 100644 index 0000000..be026e3 --- /dev/null +++ b/src/opennews_mcp/tools/free.py @@ -0,0 +1,54 @@ +"""Free news tools — no token required, access via /open/free_* endpoints. + +These tools provide basic news access without authentication. +For full features (72+ sources, AI analysis, real-time WebSocket), get a free token at https://6551.io/mcp. +""" + +from mcp.server.fastmcp import Context + +from opennews_mcp.app import mcp + + +@mcp.tool() +async def get_news_categories(ctx: Context) -> dict: + """Get all available news categories and subcategories. + + Returns a list of categories, each containing subcategories, + for use with the get_hot_news tool. + + This is a free tool that does not require an API token. + """ + free_api = ctx.request_context.lifespan_context.free_api + try: + result = await free_api.get_free_categories() + return {"success": True, "data": result} + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} + + +@mcp.tool() +async def get_hot_news( + category: str, + ctx: Context, + subcategory: str = "", +) -> dict: + """Get hot news and tweets by category. + + Args: + category: Category key (required). Use get_news_categories to list available keys. + subcategory: Subcategory key (optional). + + Returns: + Combined news articles and tweets for the given category. + + This is a free tool that does not require an API token. + """ + free_api = ctx.request_context.lifespan_context.free_api + try: + result = await free_api.get_free_hot( + category=category, + subcategory=subcategory, + ) + return {"success": True, "data": result} + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} diff --git a/src/opennews_mcp/tools/news.py b/src/opennews_mcp/tools/news.py index d616700..5135744 100644 --- a/src/opennews_mcp/tools/news.py +++ b/src/opennews_mcp/tools/news.py @@ -8,7 +8,7 @@ Uses POST /open/news_search as the primary data source. from mcp.server.fastmcp import Context from opennews_mcp.app import mcp -from opennews_mcp.config import clamp_limit, make_serializable, MAX_ROWS +from opennews_mcp.config import clamp_limit, make_serializable, MAX_ROWS, require_token @mcp.tool() @@ -21,6 +21,8 @@ async def get_latest_news(ctx: Context, limit: int = 10) -> dict: Args: limit: Maximum number of articles to return (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -45,6 +47,8 @@ async def search_news(keyword: str, ctx: Context, limit: int = 10) -> dict: keyword: Search term (e.g. "bitcoin", "SEC", "ETF"). limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -69,6 +73,8 @@ async def search_news_by_coin(coin: str, ctx: Context, limit: int = 10) -> dict: coin: Coin symbol or name (e.g. "BTC", "ETH", "SOL", "TRUMP"). limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -99,6 +105,8 @@ async def get_news_by_source(engine_type: str, news_type: str, ctx: Context, lim "Large Liquidation", "Market Trends", "OI Change". limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -127,6 +135,8 @@ async def get_news_by_engine(engine_type: str, ctx: Context, limit: int = 10) -> engine_type: The engine type code. limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -161,6 +171,8 @@ async def search_news_advanced( has_coin: If true, only return news that have associated coins. limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) @@ -203,6 +215,8 @@ async def get_high_score_news(ctx: Context, min_score: int = 70, limit: int = 10 min_score: Minimum score threshold (default 70). limit: Maximum results to return (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: @@ -235,6 +249,8 @@ async def get_news_by_signal(signal: str, ctx: Context, limit: int = 10) -> dict signal: The signal type: "long" (bullish), "short" (bearish), or "neutral". limit: Maximum results (default 10, max 100). """ + if (err := require_token()): + return err api = ctx.request_context.lifespan_context.api limit = clamp_limit(limit) try: diff --git a/src/opennews_mcp/tools/realtime.py b/src/opennews_mcp/tools/realtime.py index 60c6e08..6266047 100644 --- a/src/opennews_mcp/tools/realtime.py +++ b/src/opennews_mcp/tools/realtime.py @@ -3,7 +3,7 @@ from mcp.server.fastmcp import Context from opennews_mcp.app import mcp -from opennews_mcp.config import make_serializable +from opennews_mcp.config import make_serializable, require_token @mcp.tool() @@ -31,6 +31,8 @@ async def subscribe_latest_news( engine_types: Engine type filter in format "type1:cat1,cat2;type2:cat3". has_coin: If true, only receive news that have associated coins. """ + if (err := require_token()): + return err ws = ctx.request_context.lifespan_context.ws wait_seconds = min(max(1, wait_seconds), 30) max_items = min(max(1, max_items), 20) @@ -48,7 +50,7 @@ async def subscribe_latest_news( engine_types_dict[engine] = cat_list try: - sub_result = await ws.subscribe_latest( + await ws.subscribe_latest( engine_types=engine_types_dict, coins=coin_list, has_coin=has_coin,