diff --git a/.env.example b/.env.example index ead3aff..3734931 100644 --- a/.env.example +++ b/.env.example @@ -17,5 +17,3 @@ DANYAPI_LOG_BACKUP_COUNT=3 DANYAPI_USAGE_ENABLED=1 DANYAPI_USAGE_MAX_RECORDS=1000 DANYAPI_AUTO_UPDATE=1 -BYOK=0 -BYOK_MODE=0 diff --git a/README.md b/README.md index 9af9b47..be19506 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DanyAPI -OpenAI-compatible HTTP API built on Python + FastAPI. Instead of the paid APIs it talks to the internal APIs of the free web clients [chat.deepseek.com](https://chat.deepseek.com) and [chat.qwen.ai](https://chat.qwen.ai) using server-side accounts created from your own free provider tokens (`DEEPSEEK_TOKENS` / `QWEN_TOKENS`). API consumers need no keys - all upstream requests are made by the configured server tokens. +OpenAI compatible HTTP API built on Python + FastAPI. Instead of the paid APIs it talks to the internal APIs of the free web clients. [![CI](https://img.shields.io/github/actions/workflow/status/FANATFANATA/DanyAPI/ci.yml?branch=prod)](https://github.com/FANATFANATA/DanyAPI/actions) [![GitHub Release](https://img.shields.io/github/v/release/FANATFANATA/DanyAPI?sort=semver)](https://github.com/FANATFANATA/DanyAPI/releases) @@ -8,18 +8,31 @@ OpenAI-compatible HTTP API built on Python + FastAPI. Instead of the paid APIs i [![Docker](https://img.shields.io/badge/GHCR-ghcr.io%2Ffanatfanata%2Fdanyapi-blue)](https://github.com/FANATFANATA/DanyAPI/pkgs/container/danyapi) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/FANATFANATA/DanyAPI/blob/prod/LICENSE) -## Public hosted instance (full free) +## Public hosted instance -Don't want to self-host? A public, fully free instance is already running in production - no signup, no keys, no limits on your side: +A public instance is already running in production (BYOK_MODE=1): - API base URL: `https://danyapi.cloudpub.ru/v1/` -- Landing page: `https://danyapi.cloudpub.ru/` -Point any OpenAI-compatible client at `https://danyapi.cloudpub.ru/v1/` with a dummy `api_key` and it just works. The instance is backed by the same free provider tokens described below; treat it as best-effort. +Point any OpenAI compatible client at API base URL with a valid tokens, unauthenticated requests are rejected with 401. The API key should be the raw token (e.g. "token1,token2", same in .env). + +### Example request + +```bash +curl -X POST https://danyapi.cloudpub.ru/v1/chat/completions \ + -H "Authorization: Bearer token1,token2,token3" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-v4.1-flash-thinking", + "messages": [ + {"role": "user", "content": "Hi from example request!"} + ] + }' +``` ## Install & Upgrade -Requires Python 3.10+ (CI tests 3.10-3.14). +Requires Python 3.10+. Windows (PowerShell): @@ -42,25 +55,6 @@ docker run -d -p 8000:8000 \ ghcr.io/fanatfanata/danyapi:latest ``` -## BYOK mode (bring your own key) - -Set `BYOK=1` (or `BYOK_MODE=1`) in `.env` to switch from server-side `.env` tokens to per-request provider tokens. In this mode the client passes its own provider token(s) directly as the API key, and DanyAPI uses them for upstream requests instead of `DEEPSEEK_TOKENS`/`QWEN_TOKENS`: - -```python -from openai import OpenAI - -client = OpenAI( - base_url="https://your-instance/v1/", - api_key="your-deepseek-or-qwen-token", # sent upstream to the provider -) -``` - -- The key is read from `Authorization: Bearer `, `x-api-key` header, or the `api_key` body field (priority in that order). -- Several keys can be supplied comma-separated (`api_key="tok1,tok2"`) - each valid key adds a parallel account, exactly like multiple `.env` tokens. -- Models still select the provider: `deepseek-*` / listed DeepSeek models go to DeepSeek, `qwen*` / listed Qwen models go to Qwen with their corresponding key type. -- Requests without a key (or with an invalid key for the selected provider) are rejected with `401`. -- Qwen model list is fetched lazily from the first valid Qwen key used. - ## Contacts [Creator](https://t.me/DanyaVoredom) · [Telegram channel](https://t.me/DanyAPIFree) · [Website](https://fanatfanata.github.io/DanyAPI/) diff --git a/app.py b/app.py index c299726..5aa3bd5 100644 --- a/app.py +++ b/app.py @@ -70,7 +70,7 @@ def build_solver() -> None: str(src_path), f"/Fe:{bin_path}", ] - res = subprocess.run(cmd, capture_output=True, text=True) + res = subprocess.run(cmd, capture_output=True, text=True, check=False) success = res.returncode == 0 else: cmd_fast = [ @@ -82,7 +82,7 @@ def build_solver() -> None: "-o", str(bin_path), ] - res = subprocess.run(cmd_fast, capture_output=True, text=True) + res = subprocess.run(cmd_fast, capture_output=True, text=True, check=False) if res.returncode == 0: success = True else: @@ -94,7 +94,7 @@ def build_solver() -> None: "-o", str(bin_path), ] - res2 = subprocess.run(cmd_compat, capture_output=True, text=True) + res2 = subprocess.run(cmd_compat, capture_output=True, text=True, check=False) success = res2.returncode == 0 if success: diff --git a/danyapi/api/openai.py b/danyapi/api/openai.py index f527dee..e208507 100644 --- a/danyapi/api/openai.py +++ b/danyapi/api/openai.py @@ -2321,6 +2321,57 @@ async def _stream_openai( ], } ) + for event in incremental.finish(): + if event.event == "ready" and isinstance(event.data, dict): + response_message_id = event.data.get("response_message_id") + if response_message_id: + stop_message_id = response_message_id + rec.handle(event) + c_diff, r_diff = rec.take_diffs() + if not (c_diff or r_diff): + continue + got_content = True + if not role_sent: + role_sent = True + yield _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant"}, + "finish_reason": None, + } + ], + } + ) + delta2: dict = {} + if c_diff: + if tool_mode: + content_buf += c_diff + else: + delta2["content"] = c_diff + if r_diff: + delta2["reasoning_content"] = r_diff + if delta2: + yield _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": delta2, + "finish_reason": None, + } + ], + } + ) except BaseException: stopped = True if rec.id: diff --git a/danyapi/qwen/api.py b/danyapi/qwen/api.py index 857c66b..ba80793 100644 --- a/danyapi/qwen/api.py +++ b/danyapi/qwen/api.py @@ -680,6 +680,60 @@ async def stream_openai( for line in pending: yield line pending.clear() + for event in incremental.finish(): + rec.handle(event) + c_diff, r_diff = rec.take_diffs() + if c_diff or r_diff: + got_content = True + if not role_sent: + role_sent = True + pending.append( + _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant"}, + "finish_reason": None, + } + ], + } + ) + ) + delta2: dict = {} + if c_diff: + if tool_mode: + content_buf += c_diff + else: + delta2["content"] = c_diff + if r_diff: + delta2["reasoning_content"] = r_diff + if delta2: + pending.append( + _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": delta2, + "finish_reason": None, + } + ], + } + ) + ) + if got_content: + for line in pending: + yield line + pending.clear() except BaseException: stopped = True if rec.response_id: diff --git a/docs/token_utility.bat b/docs/token_utility.bat new file mode 100644 index 0000000..54b5d8c --- /dev/null +++ b/docs/token_utility.bat @@ -0,0 +1,37 @@ +@echo off +setlocal +title DanyAPI Token Utility +cd /d "%~dp0" + +rem Locate a Python interpreter (any version 3.6+, 32 or 64-bit - no deps needed) +set "PY=" + +where python >nul 2>nul && set "PY=python" +if not defined PY ( + where py >nul 2>nul && set "PY=py -3" +) +if not defined PY ( + if exist "%LocalAppData%\Programs\Python\Python310-32\python.exe" ( + set "PY=%LocalAppData%\Programs\Python\Python310-32\python.exe" + ) +) + +if not defined PY ( + echo [ERROR] Python not found. Install Python 3.6+ from https://python.org + echo and make sure "Add Python to PATH" is checked. + pause + exit /b 1 +) + +echo Using interpreter: %PY% +echo. + +%PY% token_utility.py %* +if errorlevel 1 ( + echo. + echo [ERROR] Script exited with an error. + pause + exit /b 1 +) + +endlocal diff --git a/docs/token_utility.py b/docs/token_utility.py new file mode 100644 index 0000000..957f980 --- /dev/null +++ b/docs/token_utility.py @@ -0,0 +1,1141 @@ +#!/usr/bin/env python3 +""" +token_utility.py - Extract DeepSeek & Qwen tokens for DanyAPI. + +Uses your DEFAULT browser (no automation, no dependencies). + + python token_utility.py + +Flow: + 1. A tiny local server starts (127.0.0.1:8765) and a page opens in your + default browser. + 2. Step by step wizard: first drag the "🔍 Run DanyAPI token utility" button to + your bookmarks bar (one time only), click Next. + 3. The page guides you to DeepSeek: log in, click the grabber bookmark + there. The token is sent silently, the DeepSeek tab closes itself, + the wizard shows a success flash and automatically moves on to Qwen. + 4. Same for Qwen - and when both tokens are in, you land on a results + screen with your tokens ready to copy. + +Everything stays local: the server binds to 127.0.0.1 only. +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import sys +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +# Windows consoles often default to cp1252 which can't render emoji/box glyphs. +# getattr() + str() instead of direct attribute access: pylint cannot infer +# members on the sys.stdout TextIO wrapper (E1101 false positive), and a +# missing/None/empty encoding must skip the re-wrap, exactly as before. +stdout_encoding = str(getattr(sys.stdout, "encoding", "") or "") +if stdout_encoding and stdout_encoding.lower() not in ("utf-8", "utf8"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +RESULT_PORT = 8765 + +DEEPSEEK_URL = "https://chat.deepseek.com/" +QWEN_URL = "https://chat.qwen.ai/auth?action=signin" + +# ---------------------------------------------------------------------------- +# Visible text (edit here - no need to dig into the HTML below) +# +# Strings used inside the static HTML go through __KEY__ placeholders; strings +# used by the page's JavaScript are injected as JSON and read via t("key"). +# ---------------------------------------------------------------------------- + +TEXTS: dict[str, str] = { + # --- Wizard page ----------------------------------------------------- + "page_title": "DanyAPI - Token Utils", + "header_title": "Token Utilities", + "header_sub": "DanyAPI uses the internal APIs of DeepSeek and Qwen's free web clients, so it needs your auth tokens." + "
This tool simply helps you to retrieve them.", + # Step 0 - bookmarklet + "step0_heading": "One-time: add the token utility bookmarklet", + "step0_intro": "Drag this button onto your browser's bookmarks bar,
(press Ctrl+Shift+B if you don't see the bar):", + "bookmarklet_label": "🔍 Run DanyAPI token utility", + "bookmarklet_aria": "DanyAPI token utility - drag this button onto your bookmarks bar", + "step0_fineprint": "Why is this necessary? We know this looks weird and unfamiliar, but while other methods for" + " extracting tokens exist, they are not browser-agnostic. This bookmarklet will execute JavaScript in the" + " context of the provider's page (deepseek.com or qwen.ai), extract your token, and send it to this page.", + "step0_next": "I added the bookmarklet - Next →", + # Step 1 - DeepSeek + "step1_heading": "DeepSeek token", + "step1_p1": "1. Open DeepSeek by clicking the button below.", + "step1_p2": "2. Sign in to your account if needed.", + "step1_p3": "3. On the DeepSeek page, click your “🔍 Run DanyAPI token utility” bookmark.", + "step1_fineprint": "The tab closes automatically after the token is sent. If the token is valid, a green checkmark" + " will appear on this page and you will be guided to the next step within a few seconds. Overwise a red cross will" + " appear, and you can click the button below to try again.", + "step1_button": "Open DeepSeek →", + "step1_waiting": "Waiting for the DeepSeek token…", + # Step 2 - Qwen + "step2_heading": "Qwen token", + "step2_p1": "1. Open Qwen by clicking the button below.", + "step2_p2": "2. Sign in to your account if needed.", + "step2_p3": "3. On the Qwen page, click the “🔍 Run DanyAPI token utility” bookmark.", + "step2_fineprint": "The tab closes automatically after the token is sent. If the token is valid, a green checkmark" + " will appear on this page and you will be guided to the next step within a few seconds. Overwise a red cross will" + " appear, and you can click the button below to try again.", + "step2_button": "Open Qwen →", + "step2_waiting": "Waiting for the Qwen token…", + # Step 3 - done + "step3_heading": "Tokens successfully extracted!", + "step3_text": "Redirecting to your tokens…", + # Footer + "footer_public_instance": "Public Instance", + "footer_docs": "Docs", + "footer_github": "GitHub", + "footer_public_url": "https://danyapi.cloudpub.ru", + "footer_docs_url": "https://danyapi.cloudpub.ru/docs/", + "footer_github_url": "https://github.com/FANATFANATA/DanyAPI", + # --- Wizard page JS -------------------------------------------------- + "js_title_wizard": "🔑 DanyAPI token utilities", + "js_title_step1": "🤖 Step 1 of 2 - DeepSeek", + "js_title_step2": "🤖 Step 2 of 2 - Qwen", + "js_title_done": "🎉 All done!", + "js_alert_popup_blocked": "Popup blocked! Please allow popups for this page and try again.", + "js_ok_both": "✔ {provider} token received! Redirecting to your tokens shortly…", + "js_ok_deepseek": "✔ DeepSeek token received! Moving on to Qwen shortly…", + "js_ok_qwen": "✔ Qwen token received! Moving to your tokens shortly…", + "js_fail": "✖ No token found - you are probably not logged in. Log in on {provider}, then try again.", + # --- Results page ---------------------------------------------------- + "results_page_title": "DanyAPI - Your tokens", + "results_title": "Your Tokens", + "results_sub": "Tokens for DeepSeek & Qwen were successfully extracted. Use them in your .env when" + " running the API locally.

To support us, you can also add them to the public API instance:" + ' {public_url}', + "results_public_instance": "Public instance", + "results_pane_ds": "DeepSeek token", + "results_pane_qw": "Qwen token", + "results_copy": "Copy", + "results_copied": "✓ Copied", + "results_no_token": "❌ No token received - complete the setup page first.", + "results_footer_again": "Run utility again", + "results_footer_docs": "Docs", + "results_footer_github": "GitHub", + # --- Popup page (shown in the provider tab after collection) -------- + "popup_title": "Token received", + "popup_message": "✔ Token received - you can close this tab and return to the DanyAPI page.", +} + +# ---------------------------------------------------------------------------- +# Shared state +# ---------------------------------------------------------------------------- + +STATE: dict[str, Any] = { + "deepseek": None, + "qwen": None, + "deepseek_failed": False, + "qwen_failed": False, +} + +# ---------------------------------------------------------------------------- +# Shared HTML assets +# +# The setup page and the results page are visually identical shells, so these +# assets are defined once and injected through the same __key__ placeholder +# machinery used for TEXTS (see _apply_texts). All three are plain URL/URI +# strings; the setup/results templates embed them verbatim. +# ---------------------------------------------------------------------------- + +# Inline SVG favicon (DanyAPI hexagon logo) as a data: URI, URL-encoded. +FAVICON_DATA_URI = ( + "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2040%2040'%3E" + "%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E" + "%3Cstop%20offset='0'%20stop-color='%236c7bff'/%3E%3Cstop%20offset='1'%20stop-color='%2322d3ee'/%3E" + "%3C/linearGradient%3E%3C/defs%3E" + "%3Cpath%20d='M20%202.5L35%2011V29L20%2037.5L5%2029V11Z'%20fill='none'%20stroke='url(%23g)'" + "%20stroke-width='2.6'%20stroke-linejoin='round'/%3E" + "%3Cpath%20d='M20%2013.5L12.5%2026.5H27.5Z'%20fill='none'%20stroke='url(%23g)'" + "%20stroke-width='1.8'%20stroke-linejoin='round'/%3E" + "%3Ccircle%20cx='20'%20cy='13.5'%20r='3'%20fill='url(%23g)'/%3E" + "%3Ccircle%20cx='12.5'%20cy='26.5'%20r='3'%20fill='url(%23g)'/%3E" + "%3Ccircle%20cx='27.5'%20cy='26.5'%20r='3'%20fill='url(%23g)'/%3E%3C/svg%3E" +) + +# Single Google Fonts request covering all families/weights used by both pages. +FONTS_CSS_URL = ( + "https://fonts.googleapis.com/css2?" + "family=Unbounded:wght@500;700;900" + "&family=Manrope:wght@400;500;600;700;800" + "&family=JetBrains+Mono:wght@400;500;600;700" + "&display=swap" +) + +# Inline SVG feTurbulence noise overlay as a data: URI, URL-encoded. +NOISE_DATA_URI = ( + "url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='120'%20height='120'%3E" + "%3Cfilter%20id='n'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.9'%20numOctaves='2'/%3E" + "%3C/filter%3E%3Crect%20width='120'%20height='120'%20filter='url(%23n)'/%3E%3C/svg%3E" + '")' +) + +# ---------------------------------------------------------------------------- +# Bookmarklet (runs on chat.deepseek.com / chat.qwen.ai, sends token to us) +# +# IMPORTANT: this source gets collapsed into ONE line by build_bookmarklet(), +# so it must contain NO '//' comments and every statement must end with ';' +# (automatic-semicolon-insertion disappears when newlines are removed). +# ---------------------------------------------------------------------------- + +BOOKMARKLET_SOURCE = r""" +(function () { + var host = location.hostname.toLowerCase(); + var isDeepSeek = host === "chat.deepseek.com" || host.endsWith(".deepseek.com"); + var isQwen = host === "chat.qwen.ai" || host.endsWith(".qwen.ai"); + if (!isDeepSeek && !isQwen) { + alert("Please run the DanyAPI token utility on DeepSeek or Qwen."); + return; + } + var p = isDeepSeek ? "deepseek" : "qwen"; + function pt(raw, depth) { + if (typeof raw !== "string") return null; + var v = raw.trim(); + if (!v) return null; + // Only accept values that actually look like auth tokens (JWT or hex), + // never random storage junk: a logged-out page must NOT produce a + // "success". Same rules are enforced server-side in register_token(). + if (/^eyJ[A-Za-z0-9._-]{20,}/.test(v) || /^[a-f0-9]{32,}$/i.test(v)) return v; + if (depth >= 4) return null; + try { + var j = JSON.parse(v); + if (typeof j === "string") return pt(j, depth + 1); + if (typeof j === "object" && j !== null) { + // Recursively unwrap ANY JSON shape ({"value":"..."}, + // {"token":{"value":"..."}}, ...) and return the first + // token-shaped string found. + for (var key in j) { + var t = pt(j[key], depth + 1); + if (t) return t; + } + } + } catch (e) {} + return null; + } + function scan(st) { + var ks = p === "deepseek" ? ["userToken", "token"] : ["token", "userToken"]; + for (var a = 0; a < ks.length; a++) { + var t = pt(st.getItem(ks[a]), 0); + if (t) return t; + } + // Fallback scan: only entries whose KEY name mentions "token". Without + // this guard, unrelated IDs (device ids, analytics ids...) that happen to + // be 32+ hex chars get grabbed on logged-out pages and fake a success. + for (var b = 0; b < st.length; b++) { + var k = st.key(b); + if (!/token/i.test(k)) continue; + var t2 = pt(st.getItem(k), 0); + if (t2) return t2; + } + return null; + } + // The providers store auth as a known key, but may wrap refreshed values + // several times (for example {"value":"{\\"token\\":\\"...\\"}"}). + // Unwrap only those known auth records; do not inspect unrelated storage. + function opaqueValue(raw, depth) { + if (typeof raw !== "string") return null; + var value = raw.trim(); + if (!value || depth > 5) return null; + if (value.length >= 16 && !/\s/.test(value) && value.charAt(0) !== "{" && value.charAt(0) !== "[" && value.charAt(0) !== "\"") return value; + try { + var obj = JSON.parse(value); + if (typeof obj === "string") return opaqueValue(obj, depth + 1); + if (!obj || typeof obj !== "object") return null; + var preferred = ["value", "token", "accessToken", "access_token", "userToken"]; + for (var i = 0; i < preferred.length; i++) { + var found = opaqueValue(obj[preferred[i]], depth + 1); + if (found) return found; + } + } catch (e) {} + return null; + } + function find() { + var t = scan(localStorage); + if (t) return t; + t = scan(sessionStorage); + if (t) return t; + var keys = p === "deepseek" ? ["userToken", "token"] : ["token", "userToken"]; + for (var i = 0; i < keys.length; i++) { + t = opaqueValue(localStorage.getItem(keys[i]), 0) || opaqueValue(sessionStorage.getItem(keys[i]), 0); + if (t) return t; + } + return null; + } + var token = find(); + // Always report back, even with no token: the local server marks the + // attempt as failed and the wizard shows a red hint instead of a green + // checkmark, so a logged-out click can never look like a success. + // Deliver via top-level navigation, NOT fetch/sendBeacon: browsers gate + // cross-site requests to 127.0.0.1 behind a "local network / device + // services" permission prompt, but plain navigations are always allowed. + location.href = "http://127.0.0.1:__PORT__/collect?p=" + encodeURIComponent(p) + + "&t=" + encodeURIComponent(token || ""); +})() +""" + + +def build_bookmarklet(port: int) -> str: + """Collapse the readable source into a one-line javascript: URL.""" + src = BOOKMARKLET_SOURCE.replace("__PORT__", str(port)) + lines = [ln.strip() for ln in src.strip().splitlines()] + lines = [ln for ln in lines if ln and not ln.startswith("//")] + one_line = "javascript:" + " ".join(lines) + # Safety net: a stray '//' would comment out everything after it once the + # code is on a single line ("http://" is the only legitimate use). + assert "http://" in one_line + assert "//" not in one_line.replace("http://", ""), "bookmarklet source contains a // comment - it would break on one line" + return one_line + + +def html_escape(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +# ---------------------------------------------------------------------------- +# Guided wizard page (step by step) +# ---------------------------------------------------------------------------- + +SETUP_PAGE = r""" + + + + +__page_title__ + + + + + + + + + + + +
+
+ + + DanyAPI + +

__header_title__

+

__header_sub__

+
+
+ + +
+

0 __step0_heading__

+

__step0_intro__

+

+ __bookmarklet_label__ +

+

__step0_fineprint__

+ +
+ + + + + + + + + + + +
+ + + + +""" + +# ---------------------------------------------------------------------------- +# Results page +# ---------------------------------------------------------------------------- + +RESULTS_PAGE = r""" + + + + +__results_page_title__ + + + + + + + + + + + +
+
+ + + DanyAPI + +

__results_title__

+

__RESULTS_SUB__

+
+ +
+
+ +

DeepSeek

+
+
+
__results_pane_ds__
+
__DEEPSEEK_TOKEN__
+
+
+ +
+
+ +

Qwen

+
+
+
__results_pane_qw__
+
__QWEN_TOKEN__
+
+
+ + +
+ + + + +""" + + +def _apply_texts(template: str, extra: dict[str, str] | None = None) -> str: + """Fill __key__ placeholders with the TEXTS config (plus any extra values).""" + out = template + for key, value in TEXTS.items(): + out = out.replace(f"__{key}__", value) + # Shared HTML assets (identical markup on both pages). + out = out.replace("__FAVICON_DATA_URI__", FAVICON_DATA_URI) + out = out.replace("__FONTS_CSS_URL__", FONTS_CSS_URL) + out = out.replace("__NOISE_DATA_URI__", NOISE_DATA_URI) + if extra: + for key, value in extra.items(): + out = out.replace(f"__{key}__", value) + return out + + +def render_setup_page(port: int = RESULT_PORT) -> str: + texts_json = json.dumps(TEXTS, ensure_ascii=False) + return _apply_texts( + SETUP_PAGE, + { + "BOOKMARKLET": html_escape(build_bookmarklet(port)), + "DEEPSEEK_URL": DEEPSEEK_URL, + "QWEN_URL": QWEN_URL, + "HAS_DEEPSEEK": "true" if STATE["deepseek"] else "false", + "HAS_QWEN": "true" if STATE["qwen"] else "false", + "TEXTS_JSON": texts_json, + }, + ) + + +def render_results_page() -> str: + def show(tok: str | None) -> str: + return html_escape(tok) if tok else TEXTS["results_no_token"] + + public_url = TEXTS["footer_public_url"] + return _apply_texts( + RESULTS_PAGE, + { + "DEEPSEEK_TOKEN": show(STATE["deepseek"]), + "QWEN_TOKEN": show(STATE["qwen"]), + "RESULTS_SUB": TEXTS["results_sub"].replace("{public_url}", public_url), + }, + ) + + +# ---------------------------------------------------------------------------- +# Local HTTP server +# ---------------------------------------------------------------------------- + + +SUCCESS_PAGE = r""" +__popup_title__ + + + + + + +""" + + +def register_token(provider: str, token: str) -> bool: + """Validate and store a token. Returns True on success. + + Must stay in sync with the bookmarklet's client-side checks: only + token-shaped values from the provider's auth storage count, so a logged-out + provider page (or random localStorage junk) cannot be registered. An + empty/invalid token records a failed attempt instead (visible in /status). + """ + if provider not in ("deepseek", "qwen"): + return False + # The provider can rotate from JWT/hex to an opaque bearer value and may + # use characters outside the URL-safe subset. The bookmarklet only sends + # values from the provider's known auth record, so validate shape here + # without imposing a token alphabet. + if token and re.fullmatch(r"\S{16,4096}", token): + STATE[provider] = token + STATE[provider + "_failed"] = False + print(f" ✔ {provider} token received ({len(token)} chars)") + return True + STATE[provider + "_failed"] = True + print(f" ✖ {provider}: no valid token found (user probably not logged in)") + return False + + +class Handler(BaseHTTPRequestHandler): + # Port the setup page's bookmarklet should call back to. Kept as a class + # attribute (set by serve()) so the handler needs no module-global state. + serve_port: int = RESULT_PORT + + def _send(self, body: bytes, status: int = 200, ctype: str = "text/html; charset=utf-8") -> None: + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + # CORS + Private Network Access: requests arriving here come from + # https://chat.deepseek.com etc. Chrome requires these headers or it + # silently drops the request (PNA preflight). + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + if self.headers.get("Access-Control-Request-Private-Network"): + self.send_header("Access-Control-Allow-Private-Network", "true") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + if self.headers.get("Access-Control-Request-Private-Network"): + self.send_header("Access-Control-Allow-Private-Network", "true") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self) -> None: + from urllib.parse import parse_qs, urlsplit + + parts = urlsplit(self.path) + path = parts.path + if path == "/status": + self._send(json.dumps({k: v for k, v in STATE.items() if not k.endswith("_failed") or v}).encode(), ctype="application/json") + elif path == "/results": + self._send(render_results_page().encode()) + elif path == "/collect": + # Top-level navigation fallback: /collect?p=deepseek&t=TOKEN + qs = parse_qs(parts.query) + provider = (qs.get("p") or [""])[0] + token = (qs.get("t") or [""])[0].strip() + if register_token(provider, token): + self._send(SUCCESS_PAGE.encode()) + else: + # Invalid/missing token: still close the tab like a success - + # the wizard itself shows the red "no token" hint via /status. + self._send(SUCCESS_PAGE.encode()) + else: # "/" and anything else -> setup page + self._send(render_setup_page(port=self.serve_port).encode()) + + def do_POST(self) -> None: + if self.path.split("?")[0] != "/collect": + self._send(b"not found", 404) + return + try: + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length).decode("utf-8", errors="replace") + # sendBeacon may send text/plain; strip junk before parsing + data = json.loads(raw) + if register_token(data.get("provider") or "", (data.get("token") or "").strip()): + self._send(b'{"ok":true}', ctype="application/json") + else: + self._send(b'{"ok":false}', 400, ctype="application/json") + except Exception: + self._send(b'{"ok":false}', 400, ctype="application/json") + + def log_message(self, *args: Any) -> None: + pass + + +def serve(port: int = RESULT_PORT, open_browser: bool = True) -> None: + Handler.serve_port = port + server = ThreadingHTTPServer(("127.0.0.1", port), Handler) + url = f"http://127.0.0.1:{port}" + print(f" Local page: {url}") + print(" (Ctrl+C in this window to stop when you're done.)\n") + if open_browser: + print(" Opening your default browser…") + webbrowser.open_new_tab(url) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +# ---------------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser(description="Extract DeepSeek & Qwen tokens for DanyAPI (uses your default browser)") + parser.add_argument("--port", type=int, default=RESULT_PORT, help=f"local server port (default {RESULT_PORT})") + parser.add_argument("--no-browser", action="store_true", help="don't auto-open the browser") + args = parser.parse_args() + + print("=" * 60) + print(" DanyAPI token extractor") + print(" DeepSeek + Qwen → DEEPSEEK_TOKENS / QWEN_TOKENS") + print("=" * 60) + print(" 1. Drag the grabber button to your bookmarks bar (once), click Next") + print(" 2. DeepSeek: log in, click the grabber bookmark - page auto-advances") + print(" 3. Qwen: same again - then both tokens are shown automatically\n") + + serve(port=args.port, open_browser=not args.no_browser) + + +if __name__ == "__main__": + main() diff --git a/docs/token_utility.sh b/docs/token_utility.sh new file mode 100644 index 0000000..aee1d9d --- /dev/null +++ b/docs/token_utility.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -e +cd "$(dirname "$0")" + +# Locate a usable Python 3 interpreter. command -v alone is not enough: some +# systems ship a python3 launcher that is broken or points at a dead install, +# so each candidate is actually executed before it is accepted. +PY="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c "import sys" >/dev/null 2>&1; then + PY="$candidate" + break + fi +done + +if [ -z "$PY" ]; then + echo "Python 3 is required but was not found in PATH." + exit 1 +fi + +echo "Using interpreter: $PY" +echo + +"$PY" token_utility.py "$@"