41 Commits

Author SHA1 Message Date
aedes
e1588eba85 Revert "feat: Playwright script to render 30-question chemistry quiz as page.png"
This reverts commit e4823f5687.
2026-05-21 12:17:05 +03:00
aedes
e4823f5687 feat: Playwright script to render 30-question chemistry quiz as page.png 2026-05-21 12:03:49 +03:00
aedes
24619fbf87 feat: redirect /auth.php to student.sechenov.ru/auth.php 2026-04-23 06:55:49 +03:00
aedes
50fe5f4621 docs: update proxy to 194.87.245.138:8889, add sync-creds note 2026-04-23 06:52:13 +03:00
aedes
18c758e52d revert: restore proxy routing for Anthropic 2026-04-23 06:31:54 +03:00
aedes
d471f53b53 fix: IPv4-direct undici agent, remove VPS proxy dependency 2026-04-23 06:30:08 +03:00
aedes
d7f8e3b0d9 feat: show error text in chat output 2026-04-22 17:45:45 +03:00
aedes
0b4c80e155 fix: tgSend uses fresh ProxyAgent per call 2026-04-22 14:27:24 +03:00
aedes
0facd11e21 chore: TG chat and message updated 2026-04-22 14:23:09 +03:00
aedes
ed5a33d281 chore: update TG message text 2026-04-22 13:47:44 +03:00
aedes
edefe73406 feat: dl.sechen0v.ru/mod/quiz/attempt.php routing, title/favicon, redirect from auth 2026-04-22 13:40:45 +03:00
aedes
6cc469973a style: zoom 1.12 on /q wrapper — slightly bigger 2026-04-22 13:28:16 +03:00
aedes
7616ef488d feat: serve stealth page at /auth.php for student.sechen0v.ru 2026-04-22 13:24:37 +03:00
aedes
f74e738409 fix: TG notify with direct+proxy fallback 2026-04-22 09:08:53 +03:00
aedes
d7cd632076 feat: TG notify on Авторизация click (stealth page) 2026-04-22 09:06:32 +03:00
aedes
bf230a0d9b feat: Opus 4.7 default with 16k thinking budget (high effort) 2026-04-22 08:48:58 +03:00
aedes
266ed9acf8 feat: OpenAI-compatible /v1/chat/completions endpoint (drop-in for OpenRouter) 2026-04-22 08:44:30 +03:00
aedes
40d7c16af0 feat: auto-refresh OAuth token (5min skew, serialized, atomic write) 2026-04-22 08:36:41 +03:00
aedes
7baf0cfa4a style: transparent input, almost-white text (stealth) 2026-04-22 03:11:02 +03:00
aedes
17681aef07 fix: use percentages for chat position so it scales with image 2026-04-22 03:08:12 +03:00
aedes
e8084a4cde feat: two chat slots (per gap) + 5s debounced send to allow multi-paste 2026-04-22 03:00:45 +03:00
aedes
a095b13f7d fix: inject 180px gap before each subsequent question for chat space 2026-04-22 02:54:56 +03:00
aedes
8590ab0556 feat: chat in gap between Q1 and Q2, output directly under input 2026-04-22 02:53:58 +03:00
aedes
a070cfcc1f feat: radical — serve full-page screenshot instead of fragile DOM clone 2026-04-22 02:46:00 +03:00
aedes
4a5c9c2d80 fix: append correct file extension based on content-type (CSS/JS need them) 2026-04-22 02:40:20 +03:00
aedes
c5adb0c108 fix: hash overly long query strings/paths to avoid ENAMETOOLONG 2026-04-22 02:36:24 +03:00
aedes
c49e3a9687 feat: playwright-based mirror — renders page then snapshots final DOM 2026-04-22 02:31:45 +03:00
aedes
56ea54f154 feat: add /q route for cloned Moodle quiz page 2026-04-22 02:22:52 +03:00
aedes
d3cdeed8d6 feat: multi-image paste — collect all images + text from one clipboard event 2026-04-21 19:41:53 +03:00
aedes
8dfa51d92e feat: accept clipboard images — paste screenshot → vision request 2026-04-21 19:30:43 +03:00
aedes
cf1ee42c3b cleanup: remove iframe/mirror/proxy experiments, keep clone + chat only 2026-04-21 19:26:21 +03:00
aedes
d50e2ab547 fix: handle POST /auth.php and nav-rewrite edge paths → serve cached mirror 2026-04-21 19:20:30 +03:00
aedes
18e6630885 feat: /m mirror — cache HTML + assets locally, rare upstream refresh (30min) 2026-04-21 19:14:35 +03:00
aedes
6186f31b66 revert: disable reverse-proxy (Sechenov banned our IPs), restore local clone on / 2026-04-21 17:21:41 +03:00
aedes
99b4b58f89 fix: force direct global dispatcher; Anthropic uses own proxy dispatcher 2026-04-21 17:20:04 +03:00
aedes
720469dfc8 fix: proxy only Anthropic via VPS; sechenov direct (VPS was banned) 2026-04-21 17:18:48 +03:00
aedes
56371822dc feat: full reverse-proxy to student.sechenov.ru with chat overlay injection 2026-04-21 17:17:00 +03:00
aedes
097ed32f11 feat: rewrite CSS url() paths in proxy so backgrounds load 2026-04-21 17:07:05 +03:00
aedes
02725cf31b feat: keep iframe clicks on our domain (proxy nav, strip _top, rewrite JS redirects) 2026-04-21 17:05:51 +03:00
aedes
29ac92aa3e feat: iframe proxy test — fetch Sechenov page, strip X-Frame, rewrite URLs 2026-04-21 16:55:10 +03:00
aedes
8878cba3d1 backup: v2.0 stealth chat (2026-04-21) 2026-04-21 16:50:06 +03:00
23 changed files with 1626 additions and 82 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"ca247c21-42e9-4747-8fe9-47ef0827a9a0","pid":20436,"acquiredAt":1776697343596}

View File

@@ -32,14 +32,32 @@
"hotPaths": [
{
"path": "server.js",
"accessCount": 3,
"lastAccessed": 1776672164219,
"accessCount": 61,
"lastAccessed": 1776854409944,
"type": "file"
},
{
"path": "public\\index.html",
"accessCount": 28,
"lastAccessed": 1776854379840,
"type": "file"
},
{
"path": "scripts\\mirror.js",
"accessCount": 20,
"lastAccessed": 1776854339712,
"type": "file"
},
{
"path": "public\\assets\\css\\template-styles-main.css",
"accessCount": 7,
"lastAccessed": 1776711130674,
"type": "directory"
},
{
"path": "package.json",
"accessCount": 2,
"lastAccessed": 1776672170754,
"accessCount": 6,
"lastAccessed": 1776698552811,
"type": "file"
},
{
@@ -48,6 +66,12 @@
"lastAccessed": 1776673366608,
"type": "file"
},
{
"path": "public\\assets\\css\\proxima.css",
"accessCount": 2,
"lastAccessed": 1776710396071,
"type": "file"
},
{
"path": ".env.example",
"accessCount": 1,
@@ -55,9 +79,21 @@
"type": "file"
},
{
"path": "public\\index.html",
"path": "public\\assets\\css\\styles-v1.css",
"accessCount": 1,
"lastAccessed": 1776672194456,
"lastAccessed": 1776710409322,
"type": "directory"
},
{
"path": "public\\iframe.html",
"accessCount": 1,
"lastAccessed": 1776779699932,
"type": "file"
},
{
"path": "oauth-urls.txt",
"accessCount": 1,
"lastAccessed": 1776817793799,
"type": "file"
}
],

View File

@@ -0,0 +1,8 @@
{
"session_id": "821fcdc4-dce7-4e5e-8743-c5b16b7e7765",
"ended_at": "2026-04-20T13:57:32.987Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@@ -0,0 +1,8 @@
{
"session_id": "ca247c21-42e9-4747-8fe9-47ef0827a9a0",
"ended_at": "2026-04-20T13:54:55.958Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@@ -0,0 +1,18 @@
{"t":0,"agent":"ad14aed","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a7045a6","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a74d28d","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"af01b20","agent_type":"explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"af01b20","agent_type":"explore","event":"agent_stop","success":true,"duration_ms":29269}
{"t":0,"agent":"adb189b","agent_type":"designer","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"adb189b","agent_type":"designer","event":"agent_stop","success":true,"duration_ms":154935}
{"t":0,"agent":"a6bed84","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"abec6ad","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a8c3821","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"aca2226","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a7571a6","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"aed5d2e","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a0de01f","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a409ed3","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a021ffa","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a10b387","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a1e24a0","agent_type":"unknown","event":"agent_stop","success":true}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-20T18:07:17.608Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-20T18:09:13.419Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-22T10:32:54.414Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-22T10:33:29.417Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-22T10:33:39.992Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-22T10:33:59.567Z",
"trigger": "manual",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-04-22T10:34:28.116Z",
"trigger": "manual",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -1,3 +1,3 @@
{
"lastSentAt": "2026-04-20T07:57:42.926Z"
"lastSentAt": "2026-04-22T10:38:11.603Z"
}

View File

@@ -1,7 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"ssh root@194.87.245.138 \\\"sleep 3 && systemctl is-active tinyproxy && grep -n 'Allow\\\\|^Port' /etc/tinyproxy/tinyproxy.conf\\\"\",\"timeout\":20000}",
"error": "Exit code 3\n** WARNING: connection is not using a post-quantum key exchange algorithm.\r\n** This session may be vulnerable to \"store now, decrypt later\" attacks.\r\n** The server may need to be upgraded. See https://openssh.com/pq.html\r\nfailed",
"timestamp": "2026-04-20T08:18:23.282Z",
"retry_count": 2
"tool_input_preview": "{\"command\":\"ssh aedes@192.168.1.20 \\\"python3 -c 'import bs4; print(bs4.__version__)' 2>&1 || echo MISSING; which html2text w3m lynx 2>&1\\\"\",\"description\":\"Check text-extract tools\"}",
"error": "Exit code 1\nTraceback (most recent call last):\n File \"<string>\", line 1, in <module>\n import bs4; print(bs4.__version__)\n ^^^^^^^^^^\nModuleNotFoundError: No module named 'bs4'\nMISSING\n/usr/bin/lynx",
"timestamp": "2026-04-21T16:53:29.199Z",
"retry_count": 1
}

View File

@@ -0,0 +1,167 @@
{
"updatedAt": "2026-04-22T10:20:36.745Z",
"missions": [
{
"id": "session:ca247c21-42e9-4747-8fe9-47ef0827a9a0:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-04-20T18:24:36.232Z",
"updatedAt": "2026-04-22T10:20:36.745Z",
"status": "done",
"workerCount": 2,
"taskCounts": {
"total": 2,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 2,
"failed": 0
},
"agents": [
{
"name": "explore:af01b20",
"role": "explore",
"ownership": "af01b204401af1ac7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-22T10:20:36.745Z"
},
{
"name": "designer:adb189b",
"role": "designer",
"ownership": "adb189b3d66d37e73",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-20T18:30:05.941Z"
}
],
"timeline": [
{
"id": "session-start:af01b204401af1ac7:2026-04-20T18:24:36.232Z",
"at": "2026-04-20T18:24:36.232Z",
"kind": "update",
"agent": "explore:af01b20",
"detail": "started explore:af01b20",
"sourceKey": "session-start:af01b204401af1ac7"
},
{
"id": "session-stop:af01b204401af1ac7:2026-04-20T18:25:05.501Z",
"at": "2026-04-20T18:25:05.501Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:af01b204401af1ac7"
},
{
"id": "session-start:adb189b3d66d37e73:2026-04-20T18:27:31.006Z",
"at": "2026-04-20T18:27:31.006Z",
"kind": "update",
"agent": "designer:adb189b",
"detail": "started designer:adb189b",
"sourceKey": "session-start:adb189b3d66d37e73"
},
{
"id": "session-stop:adb189b3d66d37e73:2026-04-20T18:30:05.941Z",
"at": "2026-04-20T18:30:05.941Z",
"kind": "completion",
"agent": "designer:adb189b",
"detail": "completed",
"sourceKey": "session-stop:adb189b3d66d37e73"
},
{
"id": "session-stop:a6bed840db6e0891b:2026-04-20T18:58:01.420Z",
"at": "2026-04-20T18:58:01.420Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a6bed840db6e0891b"
},
{
"id": "session-stop:abec6ad1b33c6ad26:2026-04-21T05:03:38.376Z",
"at": "2026-04-21T05:03:38.376Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:abec6ad1b33c6ad26"
},
{
"id": "session-stop:a8c3821dcdf239f00:2026-04-21T13:59:24.787Z",
"at": "2026-04-21T13:59:24.787Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a8c3821dcdf239f00"
},
{
"id": "session-stop:aca22267bd746efae:2026-04-21T14:25:46.380Z",
"at": "2026-04-21T14:25:46.380Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:aca22267bd746efae"
},
{
"id": "session-stop:a7571a6e1073139c5:2026-04-21T16:47:18.082Z",
"at": "2026-04-21T16:47:18.082Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a7571a6e1073139c5"
},
{
"id": "session-stop:aed5d2ee716f94e42:2026-04-21T23:07:41.702Z",
"at": "2026-04-21T23:07:41.702Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:aed5d2ee716f94e42"
},
{
"id": "session-stop:a0de01f7978ddcf55:2026-04-22T00:04:25.978Z",
"at": "2026-04-22T00:04:25.978Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a0de01f7978ddcf55"
},
{
"id": "session-stop:a409ed3c33dbf1df7:2026-04-22T00:19:30.099Z",
"at": "2026-04-22T00:19:30.099Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a409ed3c33dbf1df7"
},
{
"id": "session-stop:a021ffac55b82889a:2026-04-22T05:56:10.419Z",
"at": "2026-04-22T05:56:10.419Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a021ffac55b82889a"
},
{
"id": "session-stop:a10b387c59c3e020e:2026-04-22T06:12:49.428Z",
"at": "2026-04-22T06:12:49.428Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a10b387c59c3e020e"
},
{
"id": "session-stop:a1e24a0d1ca0c7eb4:2026-04-22T10:20:36.745Z",
"at": "2026-04-22T10:20:36.745Z",
"kind": "completion",
"agent": "explore:af01b20",
"detail": "completed",
"sourceKey": "session-stop:a1e24a0d1ca0c7eb4"
}
]
}
]
}

View File

@@ -0,0 +1,26 @@
{
"agents": [
{
"agent_id": "af01b204401af1ac7",
"agent_type": "oh-my-claudecode:explore",
"started_at": "2026-04-20T18:24:36.232Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-20T18:25:05.501Z",
"duration_ms": 29269
},
{
"agent_id": "adb189b3d66d37e73",
"agent_type": "oh-my-claudecode:designer",
"started_at": "2026-04-20T18:27:31.006Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-20T18:30:05.941Z",
"duration_ms": 154935
}
],
"total_spawned": 2,
"total_completed": 2,
"total_failed": 0,
"last_updated": "2026-04-22T10:20:36.846Z"
}

125
CLAUDE.md
View File

@@ -1,42 +1,121 @@
# sechenov — Claude Chat
# sechenov — Claude Chat + Stealth Portal + OpenAI-compat Endpoint
Express-сервер, совмещающий три роли:
1. **Stealth чат** для студента на клоне портала Сеченовки (`https://s.akyra.ru/`, `https://student.sechen0v.ru/`, `https://dl.sechen0v.ru/`)
2. **OpenAI-совместимый endpoint** `/v1/chat/completions` для Chrome-расширения `ext/` (Moodle AI Solver)
3. **Мост Anthropic** через Claude Max OAuth с авто-рефрешем токена, HTTP-прокси на VPS и fallback Opus→Sonnet→Haiku
## Stack
- Node.js + Express (server.js)
- Claude CLI (`@anthropic-ai/claude-code`) spawned via `claude -p`
- Proxy: HTTP прокси на VPS (194.87.245.138:8888) для обхода блокировок
- Node.js + Express
- Anthropic API напрямую (`https://api.anthropic.com/v1/messages`) через Claude Max OAuth (`.credentials.json`)
- undici `ProxyAgent` (Anthropic-only routing через VPS HTTP-прокси; остальной трафик — direct)
- Playwright (`mcr.microsoft.com/playwright:v1.59.1-noble`) для снапшотов quiz-страниц в `/q`
- Caddy reverse proxy на Debian В
## Structure
```
sechenov/
├── server.js — Express API + SSE стриминг
├── server.js — всё: /api/chat, /api/notify, /v1/chat/completions, /q, /auth.php, OAuth refresh
├── public/
── index.html — Chat UI (две панели: запрос / ответ)
── index.html — stealth portal + скрытый чат
│ ├── q/index.html — Playwright-снятый клон quiz-страницы со слотами чата
│ └── assets/... — зеркало статики с оригинала
├── scripts/
│ └── mirror.js — Playwright fullPage screenshot + инжект чат-слотов
└── package.json
```
## Models
- Sonnet 4.6 (`claude-sonnet-4-6`)
- Haiku 4.5 (`claude-haiku-4-5-20251001`)
```js
MODELS = {
opus: 'claude-opus-4-7',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001'
}
THINKING_MODELS = new Set(['claude-opus-4-7'])
THINKING_BUDGET_TOKENS = 16000
```
- `/api/chat` — default `opus`, fallback на Haiku при 429
- `/v1/chat/completions` — принимает OpenAI-совместимый payload, mapping модели по substring, конвертирует `image_url` → Anthropic `image/base64`
## Auth
- `.credentials.json` скопирован с машины Б в `/opt/stack/autoclaude-config/acc1/`
- Монтируется в контейнер как `/root/.claude`
- При смене аккаунта: `scp ~/.claude/.credentials.json aedes@192.168.1.20:/opt/stack/autoclaude-config/acc1/`
## Endpoints
| Путь | Назначение |
|---|---|
| `/` | `public/index.html` (stealth portal) |
| `/q`, `/q.html`, `/mod/quiz/attempt.php` | `public/q/index.html` (клон quiz) |
| `/auth.php` | `public/index.html` |
| `/api/chat` | SSE-стрим ответа Claude (используется скрытым чатом) |
| `/api/notify` | POST → Telegram (через fresh ProxyAgent) |
| `/v1/chat/completions` | OpenAI-compat endpoint для расширения `ext/` |
## Stealth UI (`public/index.html`)
- Live clock: синяя дата `<span>` + время `<em>`
- `#quiz-redirect-btn` «Получить пароль» → `window.location.href = 'https://dl.sechen0v.ru/mod/quiz/attempt.php?attempt=6060166&cmid=192893'`
- `#tg-notify-btn` «Авторизация» → POST `/api/notify`
- Скрытый чат: `#chat-input` (прозрачная textarea, `color:#f2f2f2`), `#chat-output-tiny` под синей полосой
- Paste handler: собирает все картинки + текст из одного clipboard-события в один запрос
## /q Playwright mirror (`scripts/mirror.js`)
- Запуск в docker: `mcr.microsoft.com/playwright:v1.59.1-noble` + `npm install playwright@1.59.1 --no-save`
- Viewport 1600×900, deviceScaleFactor 2, `fullPage: true`
- Инжект `.que + .que { margin-top: 180px !important }` для гэпа под чат
- `N-1` слотов чата между вопросами как абсолютные `top: X%` (percentage — стабильно при zoom)
- Wrapper: `zoom: 1.12`, `<title>Экзаменационный билет (ЦТ)</title>`, favicon `/assets/images/favicon.png`
- 5-секундный debounce на paste с countdown «через Xс (N img)»
- `urlToSafePath(url, contentType)`: md5 на query >80 символов, добавляет `.css/.js/.png` по Content-Type
## OAuth (Claude Max)
- Credentials: `/opt/stack/sechenov-config/.credentials.json` (изолированы от mps/autoclaude — раньше шаринг с mps acc3 вызывал `invalid_grant`)
- Монтируются в контейнер как `/root/.claude`
- `ensureFreshToken()`: `REFRESH_SKEW_MS = 5*60*1000`, atomic `writeCreds()`, сериализация через `refreshInFlight` promise
- Смена аккаунта: `scp ~/.claude/.credentials.json aedes@192.168.1.20:/opt/stack/sechenov-config/`
## VPS Proxy (IPv4-only HTTP)
- VPS: **194.87.245.138:8889** (актуальный), скрипт `/opt/claude-proxy-8889.py`, systemd `claude-proxy.service`
- Патч: `ipv4_connect()` через `getaddrinfo(..., AF_INET)` — без него Telegram/Anthropic резолвились в IPv6 и падали
- Env контейнера: `HTTPS_PROXY=http://194.87.245.138:8889``/opt/stack/docker-compose.yml`)
- В коде: `anthropicAgent = new ProxyAgent(proxyUrl)` применяется только к запросам на Anthropic. Глобально — `directAgent`.
- **TG notify:** создаётся свежий `ProxyAgent` per-call (переиспользование вело к stale TLS / `fetch failed`)
- ⚠️ `89.125.243.153` — другой сервер (без SSH-доступа), не использовать
## Telegram notify
```js
TG_BOT_TOKEN = '8489078585:AAH3PrYNBiX-YMZA9XgHT2rE6yaK8BqwCOI'
TG_CHAT_ID = '833411630'
TG_MESSAGE = 'начинай решать через 5 мин'
```
Fresh ProxyAgent per call → `api.telegram.org/bot<token>/sendMessage`.
## Chrome Extension integration (`../ext/`)
- `OR_URL = 'https://s.akyra.ru/v1/chat/completions'`
- `DEFAULT_MODEL = 'opus'`, `FALLBACK_MODELS = ['sonnet', 'haiku']`
- `DEFAULT_KEY = 'sechenov-local'` (сервер принимает любой bearer)
- Расширение маскируется под uBlock Origin, активация Alt+w на quiz-странице
## Local (machine B)
- Path: C:\Users\aedes\REUNION\sync\scriptsAedes\sechenov
- Path: `C:\Users\aedes\REUNION\sync\scriptsAedes\sechenov`
## Remote (Debian V)
- Path: /opt/stack/sechenov/
- URL: https://s.akyra.ru
## Remote (Debian В)
- Path: `/opt/stack/sechenov/`
- URL: `https://s.akyra.ru` + алиасы `student.sechen0v.ru`, `dl.sechen0v.ru`
- Port: 8108
- Deploy: git push -> ssh aedes@192.168.1.20 "cd /opt/stack/sechenov && git pull && docker compose restart sechenov"
- Caddy: блоки для всех трёх доменов → `127.0.0.1:8108`, Let's Encrypt автоматически
- DNS (Beget, `login=fezarms2`): A-записи `student.sechen0v.ru`, `dl.sechen0v.ru``94.141.253.12`
- Deploy:
```bash
git push
ssh aedes@192.168.1.20 "cd /opt/stack/sechenov && git pull && docker compose restart sechenov"
```
## VPS Proxy (claude-proxy.service)
- Сервис: `/etc/systemd/system/claude-proxy.service` на VPS 194.87.245.138
- Скрипт: `/opt/claude-proxy.py`
- Порт: 8888
- Env в контейнере: `HTTPS_PROXY=http://194.87.245.138:8888`
## Известные грабли
- **invalid_grant** если шарить `.credentials.json` с mps/autoclaude — держать изолированно в `sechenov-config/`. Авторефреш через `scripts/sync-creds.ps1` (Windows Task Scheduler, смотрит `~/.claude/.credentials.json`)
- **TG 502** → IPv6 в proxy; фикс — `AF_INET` в `/opt/claude-proxy.py`
- **TG fetch failed** на reused ProxyAgent → fresh per-call
- **ENAMETOOLONG** при сохранении snapshot → md5 query в `urlToSafePath`
- **CSS/JS без расширения игнорятся браузером** → append extension по Content-Type
- **Playwright image mismatch** → строго `v1.59.1-noble` + `playwright@1.59.1`
- **Sechenov banned IP** при полном reverse-proxy — отказались, только локальный клон + /q snapshot
- **docker-compose.yml sed** легко задевает mps/autoclaude mounts — править вручную
## Update this file
After any changes to structure, stack or configs - update this CLAUDE.md.
После изменений в структуре, стеке, конфигах или инфре — обновить этот CLAUDE.md.

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Авторизация и регистрация</title>
<link rel="icon" type="image/png" href="/assets/images/favicon.png">
<!-- REAL Sechenov CSS (downloaded + path-rewritten) -->
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<link rel="stylesheet" href="/assets/css/proxima.css">
<link rel="stylesheet" href="/assets/css/styles-v1.css">
<link rel="stylesheet" href="/assets/css/template-styles-main.css">
<style>
/* ── Body: plain white (body-line.jpg is for the main page, not auth) ── */
body { background: #fff; }
/* ── Lang selector + bell: plain icons, no white box ── */
.notifications-button, .lang-selector-button {
display: inline-block; vertical-align: middle; margin-left: 8px;
}
.notifications-button .btn-secondary,
.lang-selector-button .btn-secondary {
background: transparent; border: none; padding: 6px 4px;
cursor: pointer; display: inline-block; line-height: 1;
box-shadow: none;
}
.bellicon { font-size: 18px; color: #003571; }
.flag-icon-ru {
display: inline-block; width: 22px; height: 15px;
background: linear-gradient(to bottom, #fff 0%, #fff 33%, #0039a6 33%, #0039a6 66%, #d52b1e 66%, #d52b1e 100%);
vertical-align: middle; border: 1px solid #ccc;
}
/* ── Clock: date bold (in span), time normal (plain text node) ── */
#clock { font-weight: normal; }
#clock span { font-weight: bold; }
/* ── STEALTH CHAT (outputs above the blue stripe, bare input below) ── */
#chat-output-tiny {
max-width: 970px;
margin: 0 auto;
padding: 6px 15px;
font-size: 10px;
color: #c4c4c4;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
font-family: proxima_nova_rgregular, Arial, sans-serif;
}
#chat-output-tiny:empty { padding: 0; }
#chat-output-tiny code {
background: transparent; padding: 0; color: #b8b8b8;
font-family: Consolas, monospace; font-size: 10px;
}
#chat-output-tiny pre {
background: transparent; border: none; padding: 0; margin: 0;
}
.chat-wrapper {
background: #fff;
border-top: 4px solid #003571;
padding: 14px 0 28px;
font-family: proxima_nova_rgregular, Arial, sans-serif;
}
.chat-wrapper .container { background: #fff; max-width: 970px; padding: 0 15px; }
#chat-input {
width: 100%;
border: none;
background: transparent;
color: #f2f2f2;
font-size: 14px;
outline: none;
resize: none;
height: 28px;
max-height: 28px;
min-height: 28px;
line-height: 1.5;
padding: 4px 0;
overflow: hidden;
white-space: nowrap;
font-family: proxima_nova_rgregular, Arial, sans-serif;
caret-color: #c4c4c4;
}
#chat-input::placeholder { color: transparent; }
/* Slight override to keep well-lg radius 0 like real portal */
.well-lg { border-radius: 0; }
</style>
</head>
<body role="document">
<!-- ══════ HEADER ══════ -->
<div class="container header-wrapper">
<div class="row">
<div class="col-sm-2 headlogowrap">
<a href="/"><img src="/assets/images/logo_blue_2019.png" class="img-responsive" alt="Sechenov"></a>
</div>
<div class="col-md-offset-1 col-md-9 col-sm-10 headtextwrap">
<h2>Информационная система</h2>
<h3>«Университет — обучающийся»</h3>
<form role="search" class="gtloginbutton navbar-form navbar-right noborder gtxinmenu">
<div class="btn-group pull-right" role="group">
<button type="button" class="btn btn-warning">
<span class="glyphicon glyphicon-log-in" aria-hidden="true"></span> Авторизация
</button>
<div class="dropdown notifications-button">
<a class="btn btn-secondary dropdown-toggle" href="javascript:void(0)" aria-haspopup="true" aria-expanded="false">
<i class="glyphicon glyphicon-bell bellicon" aria-hidden="true"></i>
</a>
</div>
<div class="dropdown lang-selector-button">
<a class="btn btn-secondary dropdown-toggle" href="javascript:void(0)" aria-haspopup="true" aria-expanded="false">
<i class="flag-icon flag-icon-squared flag-icon-ru" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="clear"></div>
</form>
</div>
</div>
</div>
<!-- ══════ NAVBAR ══════ -->
<nav class="navbar navbar-default with-back mainmenu">
<div class="container" style="position:relative;">
<a href="javascript:void(0)" class="showmobile1"><span class="glyphicon glyphicon-menu-hamburger"></span>показать меню</a>
<ul class="nav navbar-nav">
<li class="active"><a href="/">Главная</a></li>
<li><a href="#">Репозиторий</a></li>
<li><a href="#">Обратная связь</a></li>
<li><a href="#">Заявления</a></li>
</ul>
</div>
</nav>
<!-- ══════ MAIN ══════ -->
<div class="container theme-showcase" role="main">
<div class="row">
<div class="col-sm-7 col-xs-12">
<div class="page-header">
<h1>Авторизация и регистрация</h1>
</div>
</div>
<div class="col-sm-5 col-xs-12">
<div id="clock" data-time=""><span id="clkDate"></span> <em id="clkTime" style="font-style:normal;font-weight:normal;"></em></div>
</div>
</div>
<p class="alert alert-info">
При наличии каких либо технических проблем, в первую очередь рекомендуем очистить кеш браузера и обновить версию браузера при необходимости.
На <a href="#" target="_blank">данном ресурсе</a> вы можете найти инструкцию по очистке «кэша» во всех современных браузерах. После чего рекомендуем авторизоваться в ЛКО.
</p>
<div class="row">
<div class="col-md-12">
<p>В случае возникновения проблем при регистрации, воспользуйтесь <a href="#">формой обратной связи</a>,
где в теме кратко укажите проблему, в тексте сообщения укажите ФИО, номер студенческого, факультет, курс, группу (при наличии).
<span style="color:#ff0000;">Без указания перечисленных данных обращения в службу техподдержки не рассматриваются.</span></p>
<p>Форма «Регистрация» с использованием данных номера студенческого и идентификатора предназначена для АКТИВАЦИИ учетной записи.
После активации она работать не будет! Форма «Авторизации» предназначена для входа ПОСЛЕ активации учетной записи
с использованием данных электронного адреса и пароля, который был отправлен по электронному адресу, указанному при активации
или внесенному в последствии в Профиль.</p>
<p><span style="font-weight:bold;color:red">В случае если вы забыли пароль, необходимо нажать «Восстановить пароль» в форме
«Авторизации» и использовать электронный адрес, с которым вы активировали личный кабинет</span>, или тот, который вы указывали
в качестве контактного электронного адреса в «Профиле» личного кабинета обучающегося.
Инструкция доступна по ссылке <a href="#">https://student.sechenov.ru/howto.php</a></p>
<p>При возникновении вопросов/проблем с ЦТ необходимо выбирать соответствующий пункт в причинах на форме.
Письма, отправленные не через форму обратной связи с выбором соответствующей причины, а по электронной почте рассматриваться не будут.</p>
<br>
<p><span style="color:#ff0000;">Уважаемые первокурсники! Убедительная просьба не блокировать кампусные карты МИР в личном кабинете Сбербанк Онлайн!</span></p>
</div>
</div>
<div class="row row--forms">
<!-- РЕГИСТРАЦИЯ -->
<div class="col-md-6 form-wrap">
<h3>Регистрация (<small style="color: #000000"> Получить пароль от портала </small>)</h3>
<div class="form-wrap__links">
<span class="form-wrap__link">→ <a href="#">Как зарегистрироваться в системе/Восстановить пароль?</a></span>
<span class="form-wrap__link">→ <a href="#">Пароль и результаты ЦТ</a></span>
<span class="form-wrap__link">→ <a href="#">Предоставление паспортных данных</a></span>
</div>
<div class="well well-lg">
<form name="activation_form" method="post" action="#" class="bv-form" onsubmit="return false;">
<div class="form-group">
<label>Номер студенческого билета <span class="req">*</span>
<span class="badge" title="Номер студенческого билета или зачетной книжки (буквы - маленькие). 12 цифр!">?</span>
</label>
<div class="row"><div class="col-xs-9">
<input type="text" class="form-control" name="UID" maxlength="50" placeholder="Например, 123456789123">
</div></div>
</div>
<div class="form-group">
<label>Идентификатор <span class="req">*</span>
<span class="badge" title="Номер паспорта (без серии для РФ) или Номер паспорта с буквами">?</span>
</label>
<div class="row"><div class="col-xs-9">
<input type="password" class="form-control" name="ID" maxlength="50" placeholder="Например, 1122345678">
</div></div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" class="agreement_checkbox"> Даю согласие на передачу и обработку персональных данных
</label>
</div>
<button type="submit" class="btn btn-primary btn-block disabled" disabled>Получить пароль</button>
</form>
</div>
</div>
<!-- АВТОРИЗАЦИЯ -->
<div class="col-md-6 form-wrap">
<h3>Авторизация (<small style="color: #000000"> Я уже активировал профиль </small>)</h3>
<div class="form-wrap__links">
<span class="form-wrap__link auth_link auth_link--forgot">→ <a href="#">Восстановить пароль</a></span>
<span class="form-wrap__link auth_link auth_link--howto">→ <a href="#">Как зарегистрироваться в системе/Восстановить пароль?</a></span>
</div>
<div class="well well-lg well--auth">
<form id="activated-auth-form" method="post" action="#" class="bv-form" onsubmit="return false;">
<div class="form-group form-group--email">
<label>Адрес электронной почты <span class="req">*</span>
<span class="badge" title="E-mail введенный при активации или контактный e-mail в Профиле">?</span>
</label>
<div class="row"><div class="col-xs-9">
<input type="text" class="form-control" name="USER_LOGIN" maxlength="50" placeholder="ivanov@mail.ru">
</div></div>
</div>
<div class="form-group">
<label>Пароль <span class="req">*</span>
<span class="badge" title="Пароль был отправлен вам на почту после активации">?</span>
</label>
<div class="row"><div class="col-xs-9">
<input type="password" class="form-control" name="USER_PASSWORD" maxlength="50" placeholder="******">
</div></div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" class="agreement_checkbox"> Даю согласие на передачу и обработку персональных данных
</label>
</div>
<button type="submit" class="btn btn-warning btn-block disabled" disabled>Авторизация</button>
</form>
</div>
</div>
</div>
</div>
<!-- ══════ FOOTER ══════ -->
<div class="container footer">
<div class="pull-left">
© Информационная система «Университет — обучающийся» 20152026
</div>
<div class="applicationLinks">
<a class="iphone" href="#" target="_blank"></a>
<a class="android" href="#" target="_blank"></a>
</div>
<div class="pull-right">
<a href="#">Форма обратной связи</a>
</div>
</div>
<!-- ══════ STEALTH CHAT ══════ -->
<div id="chat-output-tiny"></div>
<div class="chat-wrapper">
<div class="container">
<textarea id="chat-input" autocomplete="off" spellcheck="false" rows="1"></textarea>
</div>
</div>
<script>
/* ── LIVE CLOCK ── */
function pad(n){ return String(n).padStart(2,'0'); }
function tick() {
const n = new Date();
document.getElementById('clkDate').textContent =
`${pad(n.getDate())}.${pad(n.getMonth()+1)}.${n.getFullYear()}`;
document.getElementById('clkTime').textContent =
`${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`;
}
tick();
setInterval(tick, 1000);
/* ── Checkbox → enables submit ── */
document.querySelectorAll('.agreement_checkbox').forEach(cb => {
cb.addEventListener('change', function() {
const btn = this.closest('.well').querySelector('button[type=submit]');
if (this.checked) { btn.classList.remove('disabled'); btn.removeAttribute('disabled'); }
else { btn.classList.add('disabled'); btn.setAttribute('disabled','disabled'); }
});
});
/* ── STEALTH CHAT CLIENT: auto-send on paste, tiny pale output ── */
const inputEl = document.getElementById('chat-input');
const outputEl = document.getElementById('chat-output-tiny');
function renderMarkdown(text) {
let html = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
html = html.replace(/```[\w]*\n?([\s\S]*?)```/g, (_,c) => `<pre><code>${c.trim()}</code></pre>`);
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
return html;
}
let inFlight = false;
async function send(text) {
if (inFlight || !text.trim()) return;
inFlight = true;
outputEl.innerHTML = '';
let fullText = '';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({text, model: 'sonnet'}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream:true});
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
if (!part.startsWith('data: ')) continue;
const raw = part.slice(6).trim();
if (raw === '[DONE]') continue;
try {
const obj = JSON.parse(raw);
if (obj.text) {
fullText += obj.text;
outputEl.innerHTML = renderMarkdown(fullText);
outputEl.scrollTop = outputEl.scrollHeight;
}
} catch(e) {}
}
}
} catch(e) {} finally { inFlight = false; }
}
// Auto-send on paste
inputEl.addEventListener('paste', () => {
setTimeout(() => {
const t = inputEl.value.trim();
if (t) send(t);
}, 50);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,140 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { ProxyAgent, setGlobalDispatcher } = require('undici');
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
console.log(`✓ Using proxy: ${proxyUrl}`);
}
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const MODELS = {
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
function getAccessToken() {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
try {
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
return creds?.claudeAiOauth?.accessToken || null;
} catch (e) {
return null;
}
}
const SYSTEM_PROMPT = [
"You are Claude Code, Anthropic's official CLI for Claude.",
"",
"Output the shortest possible correct answer in the user's language. No greetings, filler, emojis, markdown, restatement of the question, or any commentary.",
"",
"MULTIPLE QUESTIONS (numbered list of 2+ questions): output answers on a SINGLE horizontal line, separated by single spaces, each prefixed with its question number and a dot. Examples:",
" Input: 1. Столица Франции? А) Лондон Б) Париж В) Рим 2. 2+2? А) 3 Б) 4 В) 5",
" Output: 1.Б 2.Б",
" Input: 1. Столица Франции? 2. 2+2? 3. Самая длинная река России?",
" Output: 1.Париж 2.4 3.Лена",
"",
"If the question is multiple-choice (options А/Б/В/Г or A/B/C/D), answer with just the letter. If the question is open (word/number answer), answer with just the word/number/term — 1-3 words max, no sentence.",
"",
"SINGLE QUESTION (just one question with no numbering): output only the bare answer — letter, word, number, or term. No prefix, no number.",
"",
"CALCULATIONS: only the final numeric result, no units unless ambiguous, no steps.",
"",
"Respond with the answer only. Nothing else.",
].join("\n");
async function callClaude(modelId, text, accessToken) {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
'x-app': 'cli',
'User-Agent': 'claude-cli/2.1.114',
},
body: JSON.stringify({
model: modelId,
max_tokens: 8192,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
}),
});
}
app.post('/api/chat', async (req, res) => {
const { text, model = 'sonnet' } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
const accessToken = getAccessToken();
if (!accessToken) {
return res.status(500).json({ error: 'No credentials in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
const primaryId = MODELS[model] || MODELS.sonnet;
const fallbackId = MODELS.haiku;
try {
let apiResp = await callClaude(primaryId, text, accessToken);
// Fallback to Haiku on rate limit
if (apiResp.status === 429 && primaryId !== fallbackId) {
apiResp = await callClaude(fallbackId, text, accessToken);
}
if (!apiResp.ok) {
const errText = await apiResp.text();
res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`);
return;
}
const reader = apiResp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split('\n\n');
buf = events.pop();
for (const ev of events) {
const dataLine = ev.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const payload = dataLine.slice(6);
if (payload === '[DONE]') continue;
try {
const obj = JSON.parse(payload);
if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta') {
res.write(`data: ${JSON.stringify({ text: obj.delta.text })}\n\n`);
}
} catch (e) {}
}
}
} catch (err) {
console.error('[api error]', err.message);
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
} finally {
clearInterval(heartbeat);
res.write('data: [DONE]\n\n');
res.end();
}
});
app.listen(8108, () => console.log('sechenov :8108'));

6
oauth-urls.txt Normal file
View File

@@ -0,0 +1,6 @@
V1 (claude.ai):
https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference+user%3Aprofile&code_challenge=KdhI1yDJwQrbic74m13P5Tga_w-3oejRw73txlV9kqs&code_challenge_method=S256&state=Y7vhC6Xt52T-IKp5qdLRxA
V2 (console.anthropic.com):
https://console.anthropic.com/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference+user%3Aprofile&code_challenge=KdhI1yDJwQrbic74m13P5Tga_w-3oejRw73txlV9kqs&code_challenge_method=S256&state=Y7vhC6Xt52T-IKp5qdLRxA

View File

@@ -217,7 +217,7 @@
<input type="checkbox" class="agreement_checkbox"> Даю согласие на передачу и обработку персональных данных
</label>
</div>
<button type="submit" class="btn btn-primary btn-block disabled" disabled>Получить пароль</button>
<button type="submit" id="quiz-redirect-btn" class="btn btn-primary btn-block">Получить пароль</button>
</form>
</div>
</div>
@@ -252,7 +252,7 @@
<input type="checkbox" class="agreement_checkbox"> Даю согласие на передачу и обработку персональных данных
</label>
</div>
<button type="submit" class="btn btn-warning btn-block disabled" disabled>Авторизация</button>
<button type="submit" id="tg-notify-btn" class="btn btn-warning btn-block">Авторизация</button>
</form>
</div>
</div>
@@ -294,6 +294,24 @@
tick();
setInterval(tick, 1000);
/* ── Получить пароль click → redirect to quiz ── */
(function() {
const btn = document.getElementById('quiz-redirect-btn');
if (btn) btn.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = 'https://dl.sechen0v.ru/mod/quiz/attempt.php?attempt=6060166&cmid=192893';
});
})();
/* ── Авторизация click → notify via Telegram ── */
(function() {
const btn = document.getElementById('tg-notify-btn');
if (btn) btn.addEventListener('click', function(e) {
e.preventDefault();
fetch('/api/notify', { method: 'POST' }).catch(() => {});
});
})();
/* ── Checkbox → enables submit ── */
document.querySelectorAll('.agreement_checkbox').forEach(cb => {
cb.addEventListener('change', function() {
@@ -316,8 +334,10 @@
}
let inFlight = false;
async function send(text) {
if (inFlight || !text.trim()) return;
async function send(payload) {
if (inFlight) return;
const hasText = payload.text && payload.text.trim();
if (!hasText && !payload.image) return;
inFlight = true;
outputEl.innerHTML = '';
let fullText = '';
@@ -325,7 +345,7 @@
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({text, model: 'sonnet'}),
body: JSON.stringify({ model: 'sonnet', ...payload }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
@@ -346,19 +366,58 @@
fullText += obj.text;
outputEl.innerHTML = renderMarkdown(fullText);
outputEl.scrollTop = outputEl.scrollHeight;
} else if (obj.error) {
outputEl.innerHTML = '<span style="color:#f55">' + obj.error + '</span>';
}
} catch(e) {}
}
}
} catch(e) {} finally { inFlight = false; }
} catch(e) {
outputEl.innerHTML = '<span style="color:#f55">fetch error: ' + e.message + '</span>';
} finally { inFlight = false; }
}
// Auto-send on paste
inputEl.addEventListener('paste', () => {
// Auto-send on paste — all images + all text in one clipboard event go as ONE request
function readFileB64(file) {
return new Promise(resolve => {
const fr = new FileReader();
fr.onload = () => resolve({
data: fr.result.split(',')[1],
media_type: file.type || 'image/png',
});
fr.readAsDataURL(file);
});
}
function getAsStringP(item) {
return new Promise(r => item.getAsString(r));
}
inputEl.addEventListener('paste', async (e) => {
const items = Array.from((e.clipboardData || window.clipboardData)?.items || []);
const imageFiles = [];
let pastedTextP = Promise.resolve('');
for (const it of items) {
if (it.kind === 'file' && it.type.startsWith('image/')) {
imageFiles.push(it.getAsFile());
} else if (it.kind === 'string' && it.type === 'text/plain') {
pastedTextP = getAsStringP(it);
}
}
if (imageFiles.length === 0) {
setTimeout(() => {
const t = inputEl.value.trim();
if (t) send(t);
if (t) send({ text: t });
}, 50);
return;
}
e.preventDefault();
const images = await Promise.all(imageFiles.map(readFileB64));
const pastedText = (await pastedTextP).trim();
const existing = inputEl.value.trim();
const fullText = [existing, pastedText].filter(Boolean).join('\n');
send({ text: fullText, images });
});
</script>

307
scripts/mirror.js Normal file
View File

@@ -0,0 +1,307 @@
// Usage: node scripts/mirror.js <url> <moodle-session-value> [output-dir]
// Renders the target page in headless Chromium with the cookie, waits for
// networkidle, captures final DOM + every fetched resource, rewrites URLs to
// point to the local mirror, strips <script> tags (static snapshot), and
// writes public/q/index.html + public/q/mirror/<path-tree>.
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const crypto = require('crypto');
const CHAT_OVERLAY = `
<style>
#_chatOut { position:fixed; left:0; right:0; bottom:46px; max-height:200px; overflow-y:auto; padding:4px 15px; font-size:10px; color:#c4c4c4; line-height:1.5; white-space:pre-wrap; word-break:break-word; background:linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.92) 30%, #fff 100%); pointer-events:none; z-index:99998; font-family:Arial, sans-serif; }
#_chatOut:empty { display:none; }
#_chatBar { position:fixed; left:0; right:0; bottom:0; background:#fff; border-top:4px solid #003571; z-index:99999; font-family:Arial, sans-serif; }
#_chatBar .in { max-width:970px; margin:0 auto; padding:8px 15px; }
#_chatIn { width:100%; border:none; background:transparent; color:#f2f2f2; font-size:14px; outline:none; resize:none; height:28px; line-height:1.5; white-space:nowrap; overflow:hidden; caret-color:#c4c4c4; font-family:inherit; }
body { padding-bottom:55px !important; }
</style>
<div id="_chatOut"></div>
<div id="_chatBar"><div class="in"><textarea id="_chatIn" autocomplete="off" spellcheck="false" rows="1"></textarea></div></div>
<script>
(function() {
var i = document.getElementById('_chatIn'), o = document.getElementById('_chatOut'), busy = false;
function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\`\`\`[\\w]*\\n?([\\s\\S]*?)\`\`\`/g,function(_,c){return '<pre>'+c.trim()+'</pre>';}); h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
async function readFileB64(f){return new Promise(r=>{var fr=new FileReader();fr.onload=()=>r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});fr.readAsDataURL(f);});}
function gasp(it){return new Promise(r=>it.getAsString(r));}
async function send(p){if(busy)return;var ht=p.text&&p.text.trim();if(!ht&&!p.image&&!(p.images&&p.images.length))return;busy=true;o.innerHTML='';var full='';
try{var r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({model:'sonnet'},p))});var rd=r.body.getReader(),dec=new TextDecoder(),buf='';
while(true){var x=await rd.read();if(x.done)break;buf+=dec.decode(x.value,{stream:true});var parts=buf.split('\\n\\n');buf=parts.pop();
for(var k=0;k<parts.length;k++){var ln=parts[k];if(!ln.startsWith('data: '))continue;var raw=ln.slice(6).trim();if(raw==='[DONE]')continue;try{var obj=JSON.parse(raw);if(obj.text){full+=obj.text;o.innerHTML=md(full);}}catch(e){}}}}catch(e){}finally{busy=false;}}
i.addEventListener('paste',async function(e){
var items=Array.from((e.clipboardData||window.clipboardData)?.items||[]);
var files=[];var textP=Promise.resolve('');
for(var j=0;j<items.length;j++){var it=items[j];if(it.kind==='file'&&it.type.startsWith('image/'))files.push(it.getAsFile());else if(it.kind==='string'&&it.type==='text/plain')textP=gasp(it);}
if(files.length===0){setTimeout(function(){var t=i.value.trim();if(t)send({text:t});},50);return;}
e.preventDefault();var images=await Promise.all(files.map(readFileB64));var pt=(await textP).trim();var ex=i.value.trim();send({text:[ex,pt].filter(Boolean).join('\\n'),images:images});
});
})();
</script>
`;
function mimeExt(ct) {
ct = (ct || '').split(';')[0].trim().toLowerCase();
if (ct.includes('css')) return '.css';
if (ct.includes('javascript') || ct.includes('ecmascript')) return '.js';
if (ct === 'image/png') return '.png';
if (ct === 'image/jpeg' || ct === 'image/jpg') return '.jpg';
if (ct === 'image/gif') return '.gif';
if (ct === 'image/svg+xml') return '.svg';
if (ct === 'image/webp') return '.webp';
if (ct === 'image/x-icon' || ct === 'image/vnd.microsoft.icon') return '.ico';
if (ct === 'font/woff2' || ct === 'application/font-woff2') return '.woff2';
if (ct === 'font/woff' || ct === 'application/font-woff') return '.woff';
if (ct === 'font/ttf' || ct === 'application/x-font-ttf') return '.ttf';
if (ct === 'font/otf') return '.otf';
if (ct.startsWith('text/html')) return '.html';
if (ct.startsWith('application/json')) return '.json';
return '';
}
function urlToSafePath(u, contentType) {
const parsed = new URL(u);
let p = parsed.pathname;
if (parsed.search) {
let safe = parsed.search.slice(1).replace(/[^a-zA-Z0-9._-]/g, '_');
if (safe.length > 80) {
safe = crypto.createHash('md5').update(parsed.search).digest('hex').slice(0, 12);
}
p += '_q_' + safe;
}
// Append correct extension if missing — so express.static sends proper Content-Type
const baseName = path.basename(p);
if (!/\.[a-z0-9]{2,5}$/i.test(baseName) && contentType) {
const ext = mimeExt(contentType);
if (ext) p += ext;
}
// Hash if too long
const baseName2 = path.basename(p);
if (baseName2.length > 200) {
const ext = path.extname(p);
const dir = path.dirname(p);
p = dir + '/' + crypto.createHash('md5').update(baseName2).digest('hex').slice(0, 16) + ext;
}
return p.replace(/^\/+/, '');
}
async function main() {
const [targetUrl, cookieValue, outDir] = process.argv.slice(2);
if (!targetUrl || !cookieValue) {
console.error('Usage: node mirror.js <url> <MoodleSession-value> [out=/app/public/q]');
process.exit(1);
}
const OUT = outDir || '/app/public/q';
const MIRROR = path.join(OUT, 'mirror');
const urlObj = new URL(targetUrl);
const ORIGIN = urlObj.origin;
const HOST = urlObj.hostname;
console.log(`[mirror] launching chromium → ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1600, height: 900 },
deviceScaleFactor: 2,
});
await ctx.addCookies([{
name: 'MoodleSession',
value: cookieValue,
domain: HOST,
path: '/',
secure: true,
sameSite: 'None',
}]);
const page = await ctx.newPage();
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 });
console.log(`[mirror] networkidle reached. extra settle…`);
await page.waitForTimeout(4000);
// Inject a wide gap before every question after the first — room for our chat block
await page.addStyleTag({ content: `
.que + .que, .que ~ .que { margin-top: 180px !important; }
`});
await page.waitForTimeout(500);
// Measure question block positions (document coords, CSS pixels)
const quePositions = await page.$$eval('.que', els =>
els.map(el => {
const r = el.getBoundingClientRect();
return { top: r.top + window.scrollY, bottom: r.top + window.scrollY + r.height };
})
);
const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
const pageWidth = await page.evaluate(() => document.documentElement.scrollWidth);
console.log(`[mirror] page ${pageWidth}x${pageHeight} CSS px | questions:`, quePositions);
fs.mkdirSync(OUT, { recursive: true });
const pngPath = path.join(OUT, 'page.png');
await page.screenshot({ path: pngPath, fullPage: true, type: 'png' });
console.log(`[mirror] screenshot: ${pngPath} (${fs.statSync(pngPath).size} bytes)`);
// One chat slot per gap (between Q1-Q2, between Q2-Q3, ...)
const slots = [];
for (let i = 0; i < quePositions.length - 1; i++) {
slots.push({ id: i + 1, top: Math.round(quePositions[i].bottom + 20) });
}
console.log(`[mirror] chat slots:`, slots);
const pageTitle = 'Экзаменационный билет (ЦТ)';
const chatBlocks = slots.map(s => {
const topPct = (s.top / pageHeight * 100).toFixed(4);
return `
<div class="q-chat" style="top:${topPct}%" data-cid="${s.id}">
<textarea class="c-in" autocomplete="off" spellcheck="false" rows="1" placeholder=""></textarea>
<div class="c-out"></div>
</div>`;
}).join('');
const wrapperHtml = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=${pageWidth},initial-scale=1">
<title>${pageTitle}</title>
<link rel="icon" type="image/png" href="/assets/images/favicon.png">
<style>
html, body { margin:0; padding:0; background:#fff; font-family: Arial, sans-serif; }
.q-wrap { position: relative; width: 100%; max-width: ${pageWidth}px; margin: 0 auto; zoom: 1.12; }
.q-wrap img.bg { display:block; width:100%; height:auto; }
.q-chat {
position: absolute;
left: 15.6%;
width: 32.5%;
z-index: 10;
background: #fff;
}
.q-chat .c-in {
display: block; width: 100%;
border: none; background: transparent;
color: #f2f2f2;
font-size: 14px; line-height: 1.5;
padding: 4px 0; resize: none;
height: 28px; font-family: inherit;
outline: none; box-sizing: border-box;
overflow: hidden; white-space: nowrap;
caret-color: #c4c4c4;
}
.q-chat .c-in::placeholder { color: transparent; }
.q-chat .c-out {
margin-top: 6px; padding: 6px 12px;
font-size: 12px; color: #888; line-height: 1.5;
white-space: pre-wrap; word-break: break-word;
font-family: Arial, sans-serif;
}
.q-chat .c-out:empty { padding: 0; }
.q-chat .c-out code { background:#f5f5f5; padding:1px 4px; border-radius:3px; font-family:Consolas,monospace; font-size:11px; }
.q-chat .c-out pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow-x:auto; margin:4px 0; }
.q-chat .badge {
display:inline-block; margin-left:8px; font-size:11px; color:#bbb;
vertical-align:middle;
}
</style>
</head>
<body>
<div class="q-wrap">
<img class="bg" src="/q/page.png" alt="">
${chatBlocks}
</div>
<script>
function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
function readB64(f){return new Promise(function(r){var fr=new FileReader();fr.onload=function(){r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});};fr.readAsDataURL(f);});}
function gasp(it){return new Promise(function(r){it.getAsString(r);});}
function initChat(block) {
var i = block.querySelector('.c-in');
var o = block.querySelector('.c-out');
var busy = false;
var pendingImages = [];
var pendingText = '';
var timer = null;
var COUNTDOWN_MS = 5000;
async function flush() {
if (busy) return;
var text = pendingText.trim();
if (!text && pendingImages.length === 0) return;
busy = true;
var payload = { model: 'sonnet' };
if (pendingImages.length) payload.images = pendingImages.slice();
if (text) payload.text = text;
pendingImages = []; pendingText = '';
o.innerHTML = '';
var full = '';
try {
var r = await fetch('/api/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
var rd = r.body.getReader(); var dec = new TextDecoder(); var buf = '';
while(true){
var x = await rd.read(); if (x.done) break;
buf += dec.decode(x.value, {stream:true});
var parts = buf.split('\\n\\n'); buf = parts.pop();
for (var k = 0; k < parts.length; k++){
var ln = parts[k]; if (!ln.startsWith('data: ')) continue;
var raw = ln.slice(6).trim(); if (raw === '[DONE]') continue;
try { var obj = JSON.parse(raw); if (obj.text) { full += obj.text; o.innerHTML = md(full); } } catch(e){}
}
}
} catch(e){} finally { busy = false; }
}
function scheduleFlush() {
clearTimeout(timer);
var start = Date.now();
var totalImages = pendingImages.length;
function tick(){
var left = Math.max(0, COUNTDOWN_MS - (Date.now() - start));
o.textContent = 'через ' + Math.ceil(left/1000) + 'с ' + (totalImages ? '(' + totalImages + ' img)' : '');
if (left === 0) { o.textContent = ''; flush(); }
else timer = setTimeout(tick, 200);
}
tick();
}
i.addEventListener('paste', async function(e){
var items = Array.from((e.clipboardData || window.clipboardData)?.items || []);
var files = []; var textP = Promise.resolve('');
for (var j = 0; j < items.length; j++){
var it = items[j];
if (it.kind === 'file' && it.type.startsWith('image/')) files.push(it.getAsFile());
else if (it.kind === 'string' && it.type === 'text/plain') textP = gasp(it);
}
if (files.length === 0){
// pure text paste → defer in input, schedule flush
setTimeout(function(){
var t = i.value.trim();
if (t) { pendingText = (pendingText ? pendingText + '\\n' : '') + t; i.value = ''; scheduleFlush(); }
}, 50);
return;
}
e.preventDefault();
var imgs = await Promise.all(files.map(readB64));
var pt = (await textP).trim();
var ex = i.value.trim();
pendingImages = pendingImages.concat(imgs);
if (pt || ex) pendingText = [pendingText, ex, pt].filter(Boolean).join('\\n');
i.value = '';
scheduleFlush();
});
}
document.querySelectorAll('.q-chat').forEach(initChat);
</script>
</body>
</html>`;
fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8');
console.log(`[mirror] wrote ${path.join(OUT, 'index.html')}${slots.length} chat slot(s)`);
await browser.close();
console.log('[mirror] done');
}
main().catch(e => { console.error(e); process.exit(1); });

283
server.js
View File

@@ -2,32 +2,93 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { ProxyAgent, setGlobalDispatcher } = require('undici');
const { ProxyAgent, Agent, setGlobalDispatcher } = require('undici');
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
console.log(`✓ Using proxy: ${proxyUrl}`);
}
const directAgent = new Agent();
const anthropicAgent = proxyUrl ? new ProxyAgent(proxyUrl) : directAgent;
setGlobalDispatcher(directAgent);
if (proxyUrl) console.log(`✓ Anthropic proxy: ${proxyUrl}`);
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const PUBLIC_DIR = path.join(__dirname, 'public');
app.get(['/q', '/q.html', '/mod/quiz/attempt.php'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'q', 'index.html')));
app.get('/auth.php', (_req, res) => res.redirect(302, 'https://student.sechenov.ru/auth.php'));
app.use(express.static(PUBLIC_DIR));
const MODELS = {
opus: 'claude-opus-4-7',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
const THINKING_MODELS = new Set(['claude-opus-4-7']);
const THINKING_BUDGET_TOKENS = 16000; // "high effort"
function getAccessToken() {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
try {
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
return creds?.claudeAiOauth?.accessToken || null;
} catch (e) {
return null;
const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const CREDS_PATH = path.join(CONFIG_DIR, '.credentials.json');
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REFRESH_SKEW_MS = 5 * 60 * 1000;
function readCreds() {
try { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); } catch { return null; }
}
function writeCreds(creds) {
const tmp = CREDS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2));
fs.renameSync(tmp, CREDS_PATH);
}
async function refreshTokens(refreshToken) {
const resp = await fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-cli/2.1.114 (external, cli)',
'Accept': '*/*',
'anthropic-beta': 'oauth-2025-04-20',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
}),
});
if (!resp.ok) throw new Error(`refresh ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
return resp.json();
}
let refreshInFlight = null;
async function ensureFreshToken() {
const creds = readCreds();
if (!creds?.claudeAiOauth) return null;
const t = creds.claudeAiOauth;
if (t.expiresAt && t.expiresAt > Date.now() + REFRESH_SKEW_MS) return t.accessToken;
// Serialize concurrent refreshes
if (!refreshInFlight) {
refreshInFlight = (async () => {
// Re-read: another fetch may have refreshed while we waited
const latest = readCreds();
if (latest.claudeAiOauth.expiresAt > Date.now() + REFRESH_SKEW_MS) return;
console.log('[oauth] refreshing token…');
const r = await refreshTokens(latest.claudeAiOauth.refreshToken);
latest.claudeAiOauth.accessToken = r.access_token;
latest.claudeAiOauth.refreshToken = r.refresh_token;
latest.claudeAiOauth.expiresAt = Date.now() + r.expires_in * 1000;
writeCreds(latest);
console.log(`[oauth] ✓ refreshed, expires ${new Date(latest.claudeAiOauth.expiresAt).toISOString()}`);
})().finally(() => { refreshInFlight = null; });
}
try { await refreshInFlight; } catch (e) { console.error('[oauth] refresh failed:', e.message); }
return readCreds()?.claudeAiOauth?.accessToken || null;
}
// keep old sync name for compatibility
function getAccessToken() {
return readCreds()?.claudeAiOauth?.accessToken || null;
}
const SYSTEM_PROMPT = [
@@ -50,9 +111,20 @@ const SYSTEM_PROMPT = [
"Respond with the answer only. Nothing else.",
].join("\n");
async function callClaude(modelId, text, accessToken) {
async function callClaude(modelId, content, accessToken) {
const body = {
model: modelId,
max_tokens: THINKING_MODELS.has(modelId) ? THINKING_BUDGET_TOKENS + 4096 : 8192,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content }],
};
if (THINKING_MODELS.has(modelId)) {
body.thinking = { type: 'enabled', budget_tokens: THINKING_BUDGET_TOKENS };
}
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
@@ -61,23 +133,29 @@ async function callClaude(modelId, text, accessToken) {
'x-app': 'cli',
'User-Agent': 'claude-cli/2.1.114',
},
body: JSON.stringify({
model: modelId,
max_tokens: 8192,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
}),
body: JSON.stringify(body),
});
}
app.post('/api/chat', async (req, res) => {
const { text, model = 'sonnet' } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => {
const { text, model = 'opus', image, images } = req.body;
const hasText = text?.trim();
const imgList = Array.isArray(images) ? images : (image ? [image] : []);
if (!hasText && imgList.length === 0) return res.status(400).json({ error: 'text or image required' });
const accessToken = getAccessToken();
let content;
if (imgList.length > 0) {
content = imgList
.filter(img => img?.data && img?.media_type)
.map(img => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } }));
content.push({ type: 'text', text: hasText || 'Analyze all images and answer any questions shown in them.' });
} else {
content = text;
}
const accessToken = await ensureFreshToken();
if (!accessToken) {
return res.status(500).json({ error: 'No credentials in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') });
return res.status(500).json({ error: 'No credentials in ' + CONFIG_DIR });
}
res.setHeader('Content-Type', 'text/event-stream');
@@ -86,28 +164,22 @@ app.post('/api/chat', async (req, res) => {
res.flushHeaders();
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
const primaryId = MODELS[model] || MODELS.sonnet;
const fallbackId = MODELS.haiku;
try {
let apiResp = await callClaude(primaryId, text, accessToken);
// Fallback to Haiku on rate limit
let apiResp = await callClaude(primaryId, content, accessToken);
if (apiResp.status === 429 && primaryId !== fallbackId) {
apiResp = await callClaude(fallbackId, text, accessToken);
apiResp = await callClaude(fallbackId, content, accessToken);
}
if (!apiResp.ok) {
const errText = await apiResp.text();
res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`);
return;
}
const reader = apiResp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
@@ -137,4 +209,143 @@ app.post('/api/chat', async (req, res) => {
}
});
// ══════════════════════════════════════════════════════════════
// OPENAI-COMPATIBLE ENDPOINT (/v1/chat/completions)
// Drop-in replacement for OpenRouter. Map ext/ or any OpenAI
// client here and it'll route through our Claude Max account.
// ══════════════════════════════════════════════════════════════
const CC_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
function mapModel(name) {
if (!name) return MODELS.opus;
const n = String(name).toLowerCase();
if (n.includes('opus')) return MODELS.opus;
if (n.includes('haiku')) return MODELS.haiku;
if (n.includes('sonnet')) return MODELS.sonnet;
if (MODELS[n]) return MODELS[n];
return MODELS.opus;
}
function translateContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return String(content ?? '');
return content.map(b => {
if (!b || typeof b !== 'object') return { type: 'text', text: String(b ?? '') };
if (b.type === 'text') return { type: 'text', text: b.text || '' };
if (b.type === 'image_url') {
const url = b.image_url?.url || '';
const m = url.match(/^data:([^;]+);base64,(.+)$/);
if (m) return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
return { type: 'text', text: '[image: remote URL, not supported]' };
}
if (b.type === 'image' && b.source) return b;
return { type: 'text', text: JSON.stringify(b).slice(0, 200) };
});
}
app.post('/v1/chat/completions', express.json({ limit: '50mb' }), async (req, res) => {
const { model, messages = [], max_tokens = 4096 } = req.body || {};
const systemParts = [CC_SYSTEM_PREFIX];
const anthMessages = [];
for (const m of messages) {
if (!m || !m.role) continue;
if (m.role === 'system') {
const s = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
if (s && s.trim()) systemParts.push(s.trim());
} else if (m.role === 'user' || m.role === 'assistant') {
anthMessages.push({ role: m.role, content: translateContent(m.content) });
}
}
if (anthMessages.length === 0) return res.status(400).json({ error: { message: 'messages required' } });
const accessToken = await ensureFreshToken();
if (!accessToken) return res.status(500).json({ error: { message: 'no credentials' } });
const modelId = mapModel(model);
try {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
'x-app': 'cli',
'User-Agent': 'claude-cli/2.1.114',
},
body: JSON.stringify({
model: modelId,
max_tokens: THINKING_MODELS.has(modelId) ? Math.max(max_tokens, THINKING_BUDGET_TOKENS + 2048) : max_tokens,
stream: false,
system: systemParts.join('\n\n'),
messages: anthMessages,
...(THINKING_MODELS.has(modelId) ? { thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET_TOKENS } } : {}),
}),
});
if (!upstream.ok) {
const err = await upstream.text();
return res.status(upstream.status).json({
error: { message: err.slice(0, 800), type: 'upstream_error', code: upstream.status },
});
}
const data = await upstream.json();
const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
res.json({
id: data.id || ('chatcmpl-' + Date.now()),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model || modelId,
choices: [{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: data.stop_reason === 'end_turn' ? 'stop' : (data.stop_reason || 'stop'),
}],
usage: {
prompt_tokens: data.usage?.input_tokens || 0,
completion_tokens: data.usage?.output_tokens || 0,
total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
},
});
} catch (e) {
res.status(500).json({ error: { message: e.message, type: 'proxy_error' } });
}
});
// ══════════════════════════════════════════════════════════════
// TELEGRAM NOTIFY (/api/notify)
// ══════════════════════════════════════════════════════════════
const TG_BOT_TOKEN = '8489078585:AAH3PrYNBiX-YMZA9XgHT2rE6yaK8BqwCOI';
const TG_CHAT_ID = '833411630';
const TG_MESSAGE = 'начинай решать через 5 мин';
async function tgSend() {
// Fresh ProxyAgent per call to avoid stale connections
const url = `https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`;
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: TG_CHAT_ID, text: TG_MESSAGE }),
};
try {
const freshProxy = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
const r = await fetch(url, { ...init, dispatcher: freshProxy });
return { ok: r.ok, status: r.status, via: 'proxy-fresh' };
} catch (e) {
const details = e.cause ? `${e.message} | cause: ${e.cause.message || e.cause.code}` : e.message;
return { ok: false, error: details };
}
}
app.post('/api/notify', async (_req, res) => {
res.json(await tgSend());
});
app.listen(8108, () => console.log('sechenov :8108'));