tokenearly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenearly/__init__.py +36 -0
- tokenearly/__main__.py +6 -0
- tokenearly/cli.py +219 -0
- tokenearly/client.py +261 -0
- tokenearly/py.typed +0 -0
- tokenearly-0.1.0.dist-info/METADATA +206 -0
- tokenearly-0.1.0.dist-info/RECORD +10 -0
- tokenearly-0.1.0.dist-info/WHEEL +4 -0
- tokenearly-0.1.0.dist-info/entry_points.txt +2 -0
- tokenearly-0.1.0.dist-info/licenses/LICENSE +21 -0
tokenearly/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Read new crypto exchange token listings from the public Tokenearly feed.
|
|
2
|
+
|
|
3
|
+
The feed is read-only and needs no account and no API key:
|
|
4
|
+
|
|
5
|
+
>>> from tokenearly import listings
|
|
6
|
+
>>> for item in listings(days=1, exchange="binance"):
|
|
7
|
+
... print(item.exchange_name, item.headline())
|
|
8
|
+
|
|
9
|
+
There is also a command line entry point:
|
|
10
|
+
|
|
11
|
+
tokenearly listings --exchange binance --type spot
|
|
12
|
+
tokenearly exchanges
|
|
13
|
+
tokenearly watch --interval 300
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .client import (
|
|
17
|
+
BASE_URL,
|
|
18
|
+
Client,
|
|
19
|
+
Exchange,
|
|
20
|
+
Listing,
|
|
21
|
+
TokenearlyError,
|
|
22
|
+
exchanges,
|
|
23
|
+
listings,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BASE_URL",
|
|
29
|
+
"Client",
|
|
30
|
+
"Exchange",
|
|
31
|
+
"Listing",
|
|
32
|
+
"TokenearlyError",
|
|
33
|
+
"exchanges",
|
|
34
|
+
"listings",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
tokenearly/__main__.py
ADDED
tokenearly/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
tokenearly listings --exchange binance --type spot
|
|
4
|
+
tokenearly exchanges
|
|
5
|
+
tokenearly watch --interval 300
|
|
6
|
+
|
|
7
|
+
Every command takes ``--json`` so the output can be piped into jq or another
|
|
8
|
+
program instead of read by a person.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
from .client import MAX_DAYS, MAX_LIMIT, Client, Listing, TokenearlyError
|
|
19
|
+
|
|
20
|
+
LANGS = ("en", "zh", "ko")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _table(rows: List[List[str]], headers: Sequence[str]) -> str:
|
|
24
|
+
"""Plain text table. No dependency, and it stays aligned in a pipe."""
|
|
25
|
+
widths = [len(h) for h in headers]
|
|
26
|
+
for row in rows:
|
|
27
|
+
for i, cell in enumerate(row):
|
|
28
|
+
widths[i] = max(widths[i], len(cell))
|
|
29
|
+
line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()
|
|
30
|
+
out = [line, " ".join("-" * w for w in widths).rstrip()]
|
|
31
|
+
for row in rows:
|
|
32
|
+
out.append(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
|
|
33
|
+
return "\n".join(out)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _truncate(text: str, width: int) -> str:
|
|
37
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _listing_row(item: Listing, lang: str, width: int) -> List[str]:
|
|
41
|
+
return [
|
|
42
|
+
(item.published_at or "")[:16].replace("T", " "),
|
|
43
|
+
item.exchange_name or item.exchange,
|
|
44
|
+
item.type,
|
|
45
|
+
",".join(item.symbols)[:24],
|
|
46
|
+
_truncate(item.headline(lang), width),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _print_listings(items: List[Listing], lang: str, width: int) -> None:
|
|
51
|
+
if not items:
|
|
52
|
+
print("No listings in that window.")
|
|
53
|
+
return
|
|
54
|
+
rows = [_listing_row(i, lang, width) for i in items]
|
|
55
|
+
print(_table(rows, ["published (utc)", "exchange", "type", "symbols", "headline"]))
|
|
56
|
+
print(f"\n{len(items)} listing(s).")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _dump(payload: Any) -> None:
|
|
60
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str)
|
|
61
|
+
sys.stdout.write("\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _client(args: argparse.Namespace) -> Client:
|
|
65
|
+
return Client(base_url=args.base_url, timeout=args.timeout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_listings(args: argparse.Namespace) -> int:
|
|
69
|
+
items = _client(args).listings(
|
|
70
|
+
days=args.days, exchange=args.exchange, type=args.type, limit=args.limit
|
|
71
|
+
)
|
|
72
|
+
if args.json:
|
|
73
|
+
_dump([i.raw for i in items])
|
|
74
|
+
else:
|
|
75
|
+
_print_listings(items, args.lang, args.width)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_exchanges(args: argparse.Namespace) -> int:
|
|
80
|
+
rows = _client(args).exchanges()
|
|
81
|
+
if args.json:
|
|
82
|
+
_dump(
|
|
83
|
+
[
|
|
84
|
+
{
|
|
85
|
+
"id": e.id,
|
|
86
|
+
"name": e.name,
|
|
87
|
+
"collection": e.collection,
|
|
88
|
+
"listings_30d": e.listings_30d,
|
|
89
|
+
"spot_30d": e.spot_30d,
|
|
90
|
+
"futures_30d": e.futures_30d,
|
|
91
|
+
"archive_url": e.archive_url,
|
|
92
|
+
}
|
|
93
|
+
for e in rows
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
return 0
|
|
97
|
+
table = [
|
|
98
|
+
[e.id, e.name, e.collection, str(e.listings_30d), str(e.spot_30d), str(e.futures_30d)]
|
|
99
|
+
for e in rows
|
|
100
|
+
]
|
|
101
|
+
print(_table(table, ["id", "name", "collection", "30d", "spot", "futures"]))
|
|
102
|
+
total = sum(e.listings_30d for e in rows)
|
|
103
|
+
ws = [e.id for e in rows if e.websocket]
|
|
104
|
+
print(f"\n{len(rows)} exchanges, {total} listings in the last 30 days.")
|
|
105
|
+
if ws:
|
|
106
|
+
print("Announcements arrive over the exchange's own WebSocket stream for: " + ", ".join(ws))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
111
|
+
client = _client(args)
|
|
112
|
+
try:
|
|
113
|
+
for item in client.watch(
|
|
114
|
+
interval=args.interval, days=args.days, exchange=args.exchange, type=args.type
|
|
115
|
+
):
|
|
116
|
+
if args.json:
|
|
117
|
+
sys.stdout.write(json.dumps(item.raw, ensure_ascii=False) + "\n")
|
|
118
|
+
else:
|
|
119
|
+
syms = f" [{' '.join(item.symbols)}]" if item.symbols else ""
|
|
120
|
+
sys.stdout.write(
|
|
121
|
+
f"{(item.published_at or '')[:16].replace('T', ' ')} "
|
|
122
|
+
f"{item.exchange_name} {item.type}{syms} "
|
|
123
|
+
f"{_truncate(item.headline(args.lang), args.width)}\n"
|
|
124
|
+
)
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
|
127
|
+
return 130
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
DEFAULTS = {
|
|
132
|
+
"base_url": "https://tokenearly.com",
|
|
133
|
+
"timeout": 20.0,
|
|
134
|
+
"json": False,
|
|
135
|
+
"lang": "en",
|
|
136
|
+
"width": 72,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _add_global_options(p: argparse.ArgumentParser, on_subparser: bool = False) -> None:
|
|
141
|
+
"""Options accepted both before and after the subcommand.
|
|
142
|
+
|
|
143
|
+
argparse only accepts a parent-level flag before the subcommand, so
|
|
144
|
+
`tokenearly listings --lang zh` fails with "unrecognized arguments" if
|
|
145
|
+
these live on the top-level parser alone. Registering them on every
|
|
146
|
+
subparser as well makes either position work.
|
|
147
|
+
|
|
148
|
+
The subparser copies use ``SUPPRESS`` as their default so they set the
|
|
149
|
+
attribute only when the flag is actually typed. With an ordinary default
|
|
150
|
+
the subparser would overwrite a value given *before* the subcommand, and
|
|
151
|
+
`tokenearly --lang zh listings` would silently print English.
|
|
152
|
+
"""
|
|
153
|
+
d = (lambda key: argparse.SUPPRESS) if on_subparser else (lambda key: DEFAULTS[key])
|
|
154
|
+
p.add_argument("--base-url", default=d("base_url"), help=argparse.SUPPRESS)
|
|
155
|
+
p.add_argument("--timeout", type=float, default=d("timeout"),
|
|
156
|
+
help="request timeout in seconds")
|
|
157
|
+
p.add_argument("--json", action="store_true", default=d("json"),
|
|
158
|
+
help="print raw JSON instead of a table")
|
|
159
|
+
p.add_argument("--lang", default=d("lang"), choices=LANGS,
|
|
160
|
+
help="headline language (default: en)")
|
|
161
|
+
p.add_argument("--width", type=int, default=d("width"), help="headline column width")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
165
|
+
parser = argparse.ArgumentParser(
|
|
166
|
+
prog="tokenearly",
|
|
167
|
+
description=(
|
|
168
|
+
"Read new crypto exchange token listings from the public Tokenearly feed. "
|
|
169
|
+
"No account, no API key."
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
_add_global_options(parser)
|
|
173
|
+
sub = parser.add_subparsers(dest="command")
|
|
174
|
+
|
|
175
|
+
def add_filters(p: argparse.ArgumentParser, default_days: int) -> None:
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"--days", type=int, default=default_days, help=f"look back N days (1-{MAX_DAYS})"
|
|
178
|
+
)
|
|
179
|
+
p.add_argument("--exchange", default="", help="exchange id, for example binance")
|
|
180
|
+
p.add_argument("--type", default="", choices=["", "spot", "futures"], help="listing type")
|
|
181
|
+
|
|
182
|
+
p_list = sub.add_parser("listings", help="recent listing announcements")
|
|
183
|
+
_add_global_options(p_list, on_subparser=True)
|
|
184
|
+
add_filters(p_list, 7)
|
|
185
|
+
p_list.add_argument("--limit", type=int, default=50, help=f"max rows (1-{MAX_LIMIT})")
|
|
186
|
+
p_list.set_defaults(func=cmd_listings)
|
|
187
|
+
|
|
188
|
+
p_ex = sub.add_parser("exchanges", help="monitored exchanges and 30-day counts")
|
|
189
|
+
_add_global_options(p_ex, on_subparser=True)
|
|
190
|
+
p_ex.set_defaults(func=cmd_exchanges)
|
|
191
|
+
|
|
192
|
+
p_watch = sub.add_parser("watch", help="poll and print each new listing once")
|
|
193
|
+
_add_global_options(p_watch, on_subparser=True)
|
|
194
|
+
add_filters(p_watch, 1)
|
|
195
|
+
p_watch.add_argument(
|
|
196
|
+
"--interval", type=float, default=300.0, help="seconds between polls (default: 300)"
|
|
197
|
+
)
|
|
198
|
+
p_watch.set_defaults(func=cmd_watch)
|
|
199
|
+
return parser
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
203
|
+
parser = build_parser()
|
|
204
|
+
args = parser.parse_args(argv)
|
|
205
|
+
if not getattr(args, "func", None):
|
|
206
|
+
parser.print_help()
|
|
207
|
+
return 2
|
|
208
|
+
try:
|
|
209
|
+
return int(args.func(args))
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
print(f"tokenearly: {exc}", file=sys.stderr)
|
|
212
|
+
return 2
|
|
213
|
+
except TokenearlyError as exc:
|
|
214
|
+
print(f"tokenearly: {exc}", file=sys.stderr)
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__": # pragma: no cover
|
|
219
|
+
raise SystemExit(main())
|
tokenearly/client.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Client for the public Tokenearly listings feed.
|
|
2
|
+
|
|
3
|
+
The feed is read-only and unauthenticated, so there is nothing to configure and
|
|
4
|
+
no account to create. Everything here is stdlib only, which keeps the install
|
|
5
|
+
small enough to drop into a cron job or a container without a resolver step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BASE_URL",
|
|
20
|
+
"Listing",
|
|
21
|
+
"Exchange",
|
|
22
|
+
"TokenearlyError",
|
|
23
|
+
"Client",
|
|
24
|
+
"listings",
|
|
25
|
+
"exchanges",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
BASE_URL = "https://tokenearly.com"
|
|
29
|
+
USER_AGENT = "tokenearly-python"
|
|
30
|
+
|
|
31
|
+
# Server-side caps, mirrored here so a bad argument fails locally with a clear
|
|
32
|
+
# message instead of being silently clamped and returning a surprising window.
|
|
33
|
+
MAX_DAYS = 30
|
|
34
|
+
MAX_LIMIT = 500
|
|
35
|
+
LISTING_TYPES = ("spot", "futures")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TokenearlyError(RuntimeError):
|
|
39
|
+
"""Raised when the feed cannot be read or returns something unusable."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Listing:
|
|
44
|
+
"""One listing announcement.
|
|
45
|
+
|
|
46
|
+
``title`` holds the headline in every language the feed publishes, so
|
|
47
|
+
``listing.headline("ko")`` works without a second request.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
exchange: str
|
|
51
|
+
exchange_name: str
|
|
52
|
+
type: str
|
|
53
|
+
symbols: List[str] = field(default_factory=list)
|
|
54
|
+
published_at: Optional[str] = None
|
|
55
|
+
title: Dict[str, str] = field(default_factory=dict)
|
|
56
|
+
source_url: str = ""
|
|
57
|
+
permalink: str = ""
|
|
58
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Listing":
|
|
62
|
+
return cls(
|
|
63
|
+
exchange=str(d.get("exchange") or ""),
|
|
64
|
+
exchange_name=str(d.get("exchange_name") or ""),
|
|
65
|
+
type=str(d.get("type") or ""),
|
|
66
|
+
symbols=[str(s) for s in (d.get("symbols") or [])],
|
|
67
|
+
published_at=d.get("published_at"),
|
|
68
|
+
title={k: str(v) for k, v in (d.get("title") or {}).items() if v},
|
|
69
|
+
source_url=str(d.get("source_url") or ""),
|
|
70
|
+
permalink=str(d.get("permalink") or ""),
|
|
71
|
+
raw=d,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def headline(self, lang: str = "en") -> str:
|
|
75
|
+
"""Headline in ``lang``, falling back through the other languages."""
|
|
76
|
+
for key in (lang, "en", "zh", "ko"):
|
|
77
|
+
value = self.title.get(key)
|
|
78
|
+
if value:
|
|
79
|
+
return value
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_futures(self) -> bool:
|
|
84
|
+
return self.type == "futures"
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
87
|
+
syms = " ".join(self.symbols)
|
|
88
|
+
return f"[{self.exchange_name}] {self.headline()}" + (f" ({syms})" if syms else "")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Exchange:
|
|
93
|
+
"""One monitored exchange and how its announcements are collected."""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
name: str
|
|
97
|
+
collection: str
|
|
98
|
+
listings_30d: int = 0
|
|
99
|
+
spot_30d: int = 0
|
|
100
|
+
futures_30d: int = 0
|
|
101
|
+
archive_url: str = ""
|
|
102
|
+
name_i18n: Dict[str, str] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Exchange":
|
|
106
|
+
return cls(
|
|
107
|
+
id=str(d.get("id") or ""),
|
|
108
|
+
name=str(d.get("name") or ""),
|
|
109
|
+
collection=str(d.get("collection") or ""),
|
|
110
|
+
listings_30d=int(d.get("listings_30d") or 0),
|
|
111
|
+
spot_30d=int(d.get("spot_30d") or 0),
|
|
112
|
+
futures_30d=int(d.get("futures_30d") or 0),
|
|
113
|
+
archive_url=str(d.get("archive_url") or ""),
|
|
114
|
+
name_i18n={k: str(v) for k, v in (d.get("name_i18n") or {}).items() if v},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def websocket(self) -> bool:
|
|
119
|
+
"""True when announcements arrive over the exchange's own WebSocket stream."""
|
|
120
|
+
return self.collection == "websocket"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Client:
|
|
124
|
+
"""Reads the public feed.
|
|
125
|
+
|
|
126
|
+
>>> from tokenearly import Client
|
|
127
|
+
>>> for item in Client().listings(days=1, exchange="binance"):
|
|
128
|
+
... print(item.exchange_name, item.headline())
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
base_url: str = BASE_URL,
|
|
134
|
+
timeout: float = 20.0,
|
|
135
|
+
retries: int = 2,
|
|
136
|
+
user_agent: str = USER_AGENT,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.base_url = base_url.rstrip("/")
|
|
139
|
+
self.timeout = timeout
|
|
140
|
+
# Retries cover the ordinary case of a dropped connection. They are
|
|
141
|
+
# deliberately not applied to 4xx, which will not become valid by
|
|
142
|
+
# asking again.
|
|
143
|
+
self.retries = max(0, int(retries))
|
|
144
|
+
self.user_agent = user_agent
|
|
145
|
+
|
|
146
|
+
# ---- transport -------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
149
|
+
query = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
|
150
|
+
url = self.base_url + path
|
|
151
|
+
if query:
|
|
152
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
153
|
+
request = urllib.request.Request(
|
|
154
|
+
url, headers={"User-Agent": self.user_agent, "Accept": "application/json"}
|
|
155
|
+
)
|
|
156
|
+
last: Optional[BaseException] = None
|
|
157
|
+
for attempt in range(self.retries + 1):
|
|
158
|
+
try:
|
|
159
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
160
|
+
body = response.read().decode("utf-8", "replace")
|
|
161
|
+
break
|
|
162
|
+
except urllib.error.HTTPError as exc:
|
|
163
|
+
# A 4xx will not fix itself; fail immediately with the status.
|
|
164
|
+
if 400 <= exc.code < 500:
|
|
165
|
+
raise TokenearlyError(f"{url} returned HTTP {exc.code}") from exc
|
|
166
|
+
last = exc
|
|
167
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
168
|
+
last = exc
|
|
169
|
+
if attempt < self.retries:
|
|
170
|
+
time.sleep(0.5 * (attempt + 1))
|
|
171
|
+
else:
|
|
172
|
+
raise TokenearlyError(f"could not read {url}: {last}") from last
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
return json.loads(body)
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
raise TokenearlyError(f"{url} did not return JSON") from exc
|
|
178
|
+
|
|
179
|
+
# ---- endpoints -------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def listings(
|
|
182
|
+
self,
|
|
183
|
+
days: int = 7,
|
|
184
|
+
exchange: str = "",
|
|
185
|
+
type: str = "", # noqa: A002 - matches the feed's parameter name
|
|
186
|
+
limit: int = 200,
|
|
187
|
+
) -> List[Listing]:
|
|
188
|
+
"""Listing announcements from the last ``days`` days, newest first.
|
|
189
|
+
|
|
190
|
+
``exchange`` takes an exchange id such as ``binance``; ``type`` takes
|
|
191
|
+
``spot`` or ``futures``. Both default to everything.
|
|
192
|
+
"""
|
|
193
|
+
if not 1 <= days <= MAX_DAYS:
|
|
194
|
+
raise ValueError(f"days must be between 1 and {MAX_DAYS}, got {days}")
|
|
195
|
+
if not 1 <= limit <= MAX_LIMIT:
|
|
196
|
+
raise ValueError(f"limit must be between 1 and {MAX_LIMIT}, got {limit}")
|
|
197
|
+
if type and type not in LISTING_TYPES:
|
|
198
|
+
raise ValueError(f"type must be one of {LISTING_TYPES}, got {type!r}")
|
|
199
|
+
|
|
200
|
+
payload = self._get(
|
|
201
|
+
"/api/public/listings.json",
|
|
202
|
+
{"days": days, "exchange": exchange.lower(), "type": type, "limit": limit},
|
|
203
|
+
)
|
|
204
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
205
|
+
if not isinstance(items, list):
|
|
206
|
+
raise TokenearlyError("listings response had no items array")
|
|
207
|
+
return [Listing.from_dict(d) for d in items if isinstance(d, dict)]
|
|
208
|
+
|
|
209
|
+
def exchanges(self) -> List[Exchange]:
|
|
210
|
+
"""The monitored exchanges, busiest first."""
|
|
211
|
+
payload = self._get("/api/public/exchanges.json")
|
|
212
|
+
rows = payload.get("exchanges") if isinstance(payload, dict) else None
|
|
213
|
+
if not isinstance(rows, list):
|
|
214
|
+
raise TokenearlyError("exchanges response had no exchanges array")
|
|
215
|
+
return [Exchange.from_dict(d) for d in rows if isinstance(d, dict)]
|
|
216
|
+
|
|
217
|
+
# ---- polling ---------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def watch(
|
|
220
|
+
self,
|
|
221
|
+
interval: float = 300.0,
|
|
222
|
+
days: int = 1,
|
|
223
|
+
exchange: str = "",
|
|
224
|
+
type: str = "", # noqa: A002
|
|
225
|
+
seen: Optional[Iterable[str]] = None,
|
|
226
|
+
) -> Iterator[Listing]:
|
|
227
|
+
"""Yield each listing once, polling every ``interval`` seconds forever.
|
|
228
|
+
|
|
229
|
+
Dedupe is by permalink and lives in memory, so a restart may re-emit
|
|
230
|
+
whatever is still inside the ``days`` window. Pass ``seen`` to carry
|
|
231
|
+
state across restarts yourself.
|
|
232
|
+
"""
|
|
233
|
+
if interval <= 0:
|
|
234
|
+
raise ValueError("interval must be positive")
|
|
235
|
+
known = set(seen or ())
|
|
236
|
+
while True:
|
|
237
|
+
try:
|
|
238
|
+
batch = self.listings(days=days, exchange=exchange, type=type, limit=MAX_LIMIT)
|
|
239
|
+
except TokenearlyError:
|
|
240
|
+
# A transient outage should not end a long-running watcher.
|
|
241
|
+
batch = []
|
|
242
|
+
for item in reversed(batch): # oldest first, so output reads chronologically
|
|
243
|
+
key = item.permalink or f"{item.exchange}:{item.published_at}:{item.headline()}"
|
|
244
|
+
if key in known:
|
|
245
|
+
continue
|
|
246
|
+
known.add(key)
|
|
247
|
+
yield item
|
|
248
|
+
time.sleep(interval)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_default = Client()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def listings(**kwargs: Any) -> List[Listing]:
|
|
255
|
+
"""Shortcut for ``Client().listings(...)``."""
|
|
256
|
+
return _default.listings(**kwargs)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def exchanges() -> List[Exchange]:
|
|
260
|
+
"""Shortcut for ``Client().exchanges()``."""
|
|
261
|
+
return _default.exchanges()
|
tokenearly/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tokenearly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read new crypto exchange token listings from the public Tokenearly feed. No account, no API key.
|
|
5
|
+
Project-URL: Homepage, https://tokenearly.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/tokenearly/tokenearly-python#readme
|
|
7
|
+
Project-URL: Source, https://github.com/tokenearly/tokenearly-python
|
|
8
|
+
Project-URL: Issues, https://github.com/tokenearly/tokenearly-python/issues
|
|
9
|
+
Project-URL: Listing timeline, https://tokenearly.com/listings
|
|
10
|
+
Project-URL: Public feed, https://tokenearly.com/api/public/listings.json
|
|
11
|
+
Author: Tokenearly
|
|
12
|
+
License: MIT License
|
|
13
|
+
|
|
14
|
+
Copyright (c) 2026 Tokenearly
|
|
15
|
+
|
|
16
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
17
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
18
|
+
in the Software without restriction, including without limitation the rights
|
|
19
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
20
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
21
|
+
furnished to do so, subject to the following conditions:
|
|
22
|
+
|
|
23
|
+
The above copyright notice and this permission notice shall be included in all
|
|
24
|
+
copies or substantial portions of the Software.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
28
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
29
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
30
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
|
+
SOFTWARE.
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Keywords: alerts,announcements,binance,bithumb,bybit,cli,crypto,cryptocurrency,exchange,listing,listings,new-listing,okx,trading,upbit
|
|
35
|
+
Classifier: Development Status :: 4 - Beta
|
|
36
|
+
Classifier: Environment :: Console
|
|
37
|
+
Classifier: Intended Audience :: Developers
|
|
38
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
39
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
40
|
+
Classifier: Operating System :: OS Independent
|
|
41
|
+
Classifier: Programming Language :: Python :: 3
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
48
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
49
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
50
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
51
|
+
Classifier: Topic :: Utilities
|
|
52
|
+
Classifier: Typing :: Typed
|
|
53
|
+
Requires-Python: >=3.8
|
|
54
|
+
Provides-Extra: dev
|
|
55
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# tokenearly
|
|
59
|
+
|
|
60
|
+
[](https://pypi.org/project/tokenearly/)
|
|
61
|
+
[](https://pypi.org/project/tokenearly/)
|
|
62
|
+
[](https://github.com/tokenearly/tokenearly-python/blob/main/LICENSE)
|
|
63
|
+
|
|
64
|
+
Read new crypto exchange token listings from the command line or from Python. **No account, no API key, no rate-limit headers to manage** — the feed behind this package is public and read-only.
|
|
65
|
+
|
|
66
|
+
```console
|
|
67
|
+
$ pip install tokenearly
|
|
68
|
+
$ tokenearly listings --exchange binance --type spot
|
|
69
|
+
published (utc) exchange type symbols headline
|
|
70
|
+
---------------- -------- ---- ------- -------------------------------------------
|
|
71
|
+
2026-09-10 08:12 Binance spot ARB Binance Will List Arbitrum (ARB)
|
|
72
|
+
2026-09-09 14:03 Binance spot PENGU Binance Will List Pudgy Penguins (PENGU)
|
|
73
|
+
|
|
74
|
+
2 listing(s).
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## How do I get notified when an exchange lists a new token?
|
|
78
|
+
|
|
79
|
+
That is the question this package exists to answer. Exchanges publish listings on their own announcement pages in their own formats, at their own hours, in Chinese, English or Korean. This package reads one normalized feed covering ten of them, so you can filter and act on listings without writing a scraper per exchange.
|
|
80
|
+
|
|
81
|
+
Three ways to use it:
|
|
82
|
+
|
|
83
|
+
```console
|
|
84
|
+
# One-off look at what has been listed recently
|
|
85
|
+
tokenearly listings --days 7
|
|
86
|
+
|
|
87
|
+
# Which exchanges are covered, and how active each has been
|
|
88
|
+
tokenearly exchanges
|
|
89
|
+
|
|
90
|
+
# Long-running: print each new listing exactly once, then pipe it anywhere
|
|
91
|
+
tokenearly watch --interval 300 --json | while read -r line; do
|
|
92
|
+
echo "$line" | jq -r '.exchange_name + " " + .title.en'
|
|
93
|
+
done
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Which exchanges are covered?
|
|
97
|
+
|
|
98
|
+
Binance, OKX, Bybit, Bitget, MEXC, Gate.io, HTX, KuCoin, Upbit and Bithumb. `tokenearly exchanges` prints the live list with a 30-day listing count for each, so you never have to trust a number in a README:
|
|
99
|
+
|
|
100
|
+
```console
|
|
101
|
+
$ tokenearly exchanges
|
|
102
|
+
id name collection 30d spot futures
|
|
103
|
+
------- -------- ---------- --- ---- -------
|
|
104
|
+
mexc MEXC polling 163 81 82
|
|
105
|
+
gate Gate.io websocket 58 22 36
|
|
106
|
+
bitget Bitget polling 51 16 35
|
|
107
|
+
...
|
|
108
|
+
10 exchanges, 467 listings in the last 30 days.
|
|
109
|
+
Announcements arrive over the exchange's own WebSocket stream for: gate, binance
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Tokenized stocks, equity CFDs and listing-commemoration giveaways are excluded, because none of them is a crypto token listing.
|
|
113
|
+
|
|
114
|
+
The `collection` column matters if latency does. Binance and Gate.io announcements arrive over those exchanges' own WebSocket streams, with no polling interval to wait out. The other eight are polled at high frequency.
|
|
115
|
+
|
|
116
|
+
## Python API
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from tokenearly import Client
|
|
120
|
+
|
|
121
|
+
client = Client()
|
|
122
|
+
|
|
123
|
+
for item in client.listings(days=1, type="spot"):
|
|
124
|
+
print(item.exchange_name, item.symbols, item.headline("en"))
|
|
125
|
+
print(item.source_url) # the exchange's own announcement
|
|
126
|
+
print(item.permalink) # stable URL, also a good dedupe key
|
|
127
|
+
|
|
128
|
+
# Titles come in three languages, so no second request is needed
|
|
129
|
+
item = client.listings(days=7, limit=1)[0]
|
|
130
|
+
item.headline("zh")
|
|
131
|
+
item.headline("ko")
|
|
132
|
+
|
|
133
|
+
# Coverage and how each exchange is collected
|
|
134
|
+
for ex in client.exchanges():
|
|
135
|
+
print(ex.id, ex.listings_30d, "websocket" if ex.websocket else "polling")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`watch()` is a generator that yields each listing once:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from tokenearly import Client
|
|
142
|
+
|
|
143
|
+
for item in Client().watch(interval=300, exchange="upbit"):
|
|
144
|
+
notify(f"{item.exchange_name}: {item.headline('ko')}")
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Dedupe inside `watch()` is by permalink and lives in memory. Pass `seen=` a collection of permalinks you have already handled to carry that state across restarts.
|
|
148
|
+
|
|
149
|
+
### Listing fields
|
|
150
|
+
|
|
151
|
+
| Field | Meaning |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `exchange` | exchange id, for example `binance` |
|
|
154
|
+
| `exchange_name` | display name, for example `Binance` |
|
|
155
|
+
| `type` | `spot` or `futures` |
|
|
156
|
+
| `symbols` | token symbols found in the announcement, for example `["ARB"]` |
|
|
157
|
+
| `published_at` | ISO 8601 UTC timestamp from the exchange |
|
|
158
|
+
| `title` | headline keyed by language: `en`, `zh`, `ko` |
|
|
159
|
+
| `source_url` | the exchange's own announcement page |
|
|
160
|
+
| `permalink` | stable URL for this announcement |
|
|
161
|
+
| `raw` | the untouched feed object, for anything not mapped above |
|
|
162
|
+
|
|
163
|
+
Use `Listing.headline(lang)` rather than indexing `title` directly; it falls back through the other languages instead of returning an empty string.
|
|
164
|
+
|
|
165
|
+
## Design notes
|
|
166
|
+
|
|
167
|
+
**Standard library only.** No `requests`, no `pydantic`, nothing to resolve. `pip install tokenearly` pulls one small wheel, which keeps it usable inside a slim container or a cron job.
|
|
168
|
+
|
|
169
|
+
**Bad arguments fail locally.** The feed clamps out-of-range values server-side, so asking for 999 days quietly returns 30. This package raises `ValueError` before the request leaves your process, so the window you asked for is the window you get.
|
|
170
|
+
|
|
171
|
+
**A 4xx is not retried.** Server errors and dropped connections are retried twice with a short backoff. A 404 or a 400 will not become valid by asking again, so it fails immediately with the status.
|
|
172
|
+
|
|
173
|
+
**`watch()` survives an outage.** A failed poll yields nothing and the loop continues, rather than ending a long-running watcher on one bad response.
|
|
174
|
+
|
|
175
|
+
## The feed itself
|
|
176
|
+
|
|
177
|
+
If you would rather not use Python at all, the same data is three plain HTTP endpoints, all unauthenticated, cached for five minutes, CORS open:
|
|
178
|
+
|
|
179
|
+
| Endpoint | Returns |
|
|
180
|
+
|---|---|
|
|
181
|
+
| [`/api/public/listings.json`](https://tokenearly.com/api/public/listings.json) | listings, with `days`, `exchange`, `type` and `limit` parameters |
|
|
182
|
+
| [`/api/public/exchanges.json`](https://tokenearly.com/api/public/exchanges.json) | monitored exchanges, collection method, 30-day counts |
|
|
183
|
+
| [`/feed/listings.xml`](https://tokenearly.com/feed/listings.xml) | the same listings as RSS 2.0 |
|
|
184
|
+
|
|
185
|
+
The feed carries headlines, category, timestamp, token symbols and a link to the original announcement. Announcement bodies are not reproduced. Data is published under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); attribute as *Data by Tokenearly (https://tokenearly.com)*.
|
|
186
|
+
|
|
187
|
+
There is also an [n8n template](https://github.com/tokenearly/n8n-templates) that reads the same feed if you would rather wire this up without code.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```console
|
|
192
|
+
git clone https://github.com/tokenearly/tokenearly-python
|
|
193
|
+
cd tokenearly-python
|
|
194
|
+
python -m pip install -e ".[dev]"
|
|
195
|
+
python -m pytest -q
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Releases are published from GitHub Actions using PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/), so there is no long-lived API token anywhere in this repository.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
Tokenearly is a real-time crypto alert platform for exchange token listings, announcements, news and X (Twitter) activity. It monitors 10 crypto exchanges (Binance, OKX, Bybit, Bitget, MEXC, Gate.io, HTX, KuCoin, Upbit, Bithumb) — Binance and Gate.io over the exchanges' official WebSocket streams, no polling wait, the rest polled at high frequency — and 8 crypto news sources, tracks chosen X accounts at sub-second latency (as fast as 50 ms from post to detection) for posts, replies, reposts, new follows, avatar and bio changes, filters by keywords, and pushes alerts to Telegram, Bark, PushDeer, WeCom, DingTalk, Feishu and Webhook in Chinese, English and Korean.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tokenearly/__init__.py,sha256=_mGAnsiM8zjGpSFJZgchSZ8KBlj0b3jLKaKTL2sNR40,755
|
|
2
|
+
tokenearly/__main__.py,sha256=ce96sgFaCn2wYzRucOcjMKzpSbvu06RyrbWYT5zzub4,118
|
|
3
|
+
tokenearly/cli.py,sha256=2BscbenwJCy3RSo16PLyf5enTdfkgdg2u5erqIrTEjg,7870
|
|
4
|
+
tokenearly/client.py,sha256=KUI9yN5KP1QAXvisG1EC6K_v2DeNlTHwdUFOozpN7cI,9350
|
|
5
|
+
tokenearly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
tokenearly-0.1.0.dist-info/METADATA,sha256=GnWDEg7KLxAxO195w5bULTwLTA8pSExKtee5YuETZqE,10356
|
|
7
|
+
tokenearly-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
tokenearly-0.1.0.dist-info/entry_points.txt,sha256=3JrNAr9i2pG-emx5CQYpucR5pPwrS0spEJNpsQYnFhI,51
|
|
9
|
+
tokenearly-0.1.0.dist-info/licenses/LICENSE,sha256=eqXW-lUbnoSkZGjgZg_E-JsUJqoKOcrMQVV7dOzjV6c,1067
|
|
10
|
+
tokenearly-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tokenearly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|