reedach-server 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dima Faller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,33 @@
1
+ # Источники и сторонние лицензии
2
+
3
+ ## Словарь правил
4
+
5
+ Правила и формулировки подсказок в `packages/core/src/rules.js` написаны для проекта
6
+ самостоятельно. Идеи взяты из открытых статей об информационном стиле:
7
+ [Максим Ильяхов](https://maximilyahov.ru), [Бюро Горбунова](https://bureau.ru) и другие авторы.
8
+ Тексты статей в проект не копировались.
9
+
10
+ Проверка не заменяет профессиональную редактуру.
11
+
12
+ ## Сторонние данные и программы
13
+
14
+ Ядро (`packages/core`) не содержит сторонних данных и зависимостей.
15
+
16
+ Сервер (`packages/server`) использует их как зависимости, не включая в этот репозиторий:
17
+
18
+ | Компонент | Лицензия | Источник |
19
+ |---|---|---|
20
+ | Az.js — морфологический анализ | MIT | https://github.com/deNULL/Az.js |
21
+ | Словари Az.js — данные OpenCorpora, сборка pymorphy2 | CC BY-SA | https://opencorpora.org |
22
+ | dictionary-ru — словарь hunspell, © 1997–2008 Alexander I. Lebedev | BSD-3-Clause | https://github.com/wooorm/dictionaries |
23
+ | dictionary-en — словарь hunspell на основе SCOWL | MIT AND BSD | https://github.com/wooorm/dictionaries |
24
+ | hunspell — проверка орфографии, запускается как отдельная программа | GPL-2.0 / LGPL-2.1 / MPL-1.1 на выбор | https://github.com/hunspell/hunspell |
25
+
26
+ Hunspell не входит в код Reedach и не распространяется вместе с ним: сервер запускает
27
+ установленную в системе программу `hunspell` отдельным процессом и передаёт ей слова. В
28
+ Docker-образ она попадает из пакета Alpine `hunspell` со своей лицензией. Словари hunspell
29
+ ставятся из npm вместе с `reedach-server` (пакеты `dictionary-ru` и `dictionary-en`) и
30
+ используются без изменений.
31
+
32
+ Словари OpenCorpora используются без изменений. Изменённые словари, если появятся,
33
+ будут распространяться под той же лицензией CC BY-SA с указанием источника.
package/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # reedach-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Сервер Reedach: орфография (hunspell), морфология (Az.js со словарями OpenCorpora) и
4
+ подсказки по стилю из ядра `reedach` — одним запросом. Работает на вашем сервере,
5
+ тексты никуда не уходят, не сохраняются и не пишутся в журнал.
6
+
7
+ Подключить можно двумя способами:
8
+
9
+ - **библиотекой** — вызывать `check()` прямо в своём Node-приложении, без отдельного сервиса;
10
+ - **по HTTP** — запустить Docker-образ и отправлять запросы с сайта.
11
+
12
+ Для орфографии нужна программа `hunspell` (в Alpine: `apk add hunspell`, в Debian и
13
+ Ubuntu: `apt install hunspell`). В Docker-образе она уже есть.
14
+
15
+ ## Библиотекой
16
+
17
+ ```bash
18
+ npm i reedach-server
19
+ ```
20
+
21
+ ```js
22
+ import {check} from 'reedach-server';
23
+
24
+ const {issues} = await check({
25
+ markdown: 'В настоящее время мы осуществляем доставку.',
26
+ ignore: ['Reedach'], // слова автора: формы тоже принимаются
27
+ disable: ['intensifiers'], // выключить правило по id или целую категорию
28
+ });
29
+ ```
30
+
31
+ ## По HTTP
32
+
33
+ ```bash
34
+ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=https://ваш-сайт.ru; ключ-для-сервера-сайта" ghcr.io/dimafaller-en/reedach
35
+ ```
36
+
37
+ Образ собран для amd64 и arm64. Собрать его самому из исходников: `docker build -t reedach .`
38
+ в корне [репозитория](https://github.com/dimafaller-en/reedach).
39
+
40
+ Без ключа сервер не запускается.
41
+
42
+ ### Ключи
43
+
44
+ Ключ — это пароль от вашего сервера Reedach. Его никто не выдаёт, регистрироваться нигде не
45
+ нужно: вы придумываете ключ сами (любой случайный набор от 16 символов, например
46
+ `openssl rand -hex 24`), передаёте серверу при запуске и сообщаете своему сайту. Без правильного
47
+ ключа сервер текст не проверяет.
48
+
49
+ `REEDACH_KEYS` — список ключей через `;`. Каждый ключ не короче 16 символов. После `=`
50
+ через запятую перечисляются сайты, со страниц которых этот ключ можно использовать
51
+ прямо из браузера.
52
+
53
+ - **Ключ с сайтами** работает со страниц этих сайтов и с любого сервера.
54
+ - **Ключ без сайтов** работает только с сервера: браузер с ним получит отказ.
55
+
56
+ Ключ в коде страницы виден любому посетителю, секретом он не является. Чужой сайт его
57
+ использовать не сможет, но скрипт вне браузера сможет, а сдержат его только лимиты.
58
+ Надёжнее отправлять запросы через сервер своего сайта и не открывать Reedach в интернет.
59
+
60
+ ### Запросы
61
+
62
+ Ключ передаётся в заголовке `Authorization: Bearer <ключ>`, тело запроса — JSON.
63
+
64
+ **`POST /v1/check`** — проверить текст. Передайте одно из трёх:
65
+
66
+ | Поле | Что это | Куда указывают `start` и `end` |
67
+ |---|---|---|
68
+ | `markdown` | Markdown-текст | в исходный текст |
69
+ | `text` | обычный текст, абзацы через пустую строку | в исходный текст |
70
+ | `blocks` | `[{text, kind}]`, где `kind` — `p`, `li` или `h` | в текст блока номер `block` |
71
+
72
+ Необязательные поля:
73
+
74
+ - `ignore` — слова автора;
75
+ - `disable` — id правил или категории, которые нужно выключить;
76
+ - `checks` — какие проверки запускать: `spelling`, `morph`, `style` (по умолчанию все).
77
+
78
+ Ответ:
79
+
80
+ ```json
81
+ {"issues": [
82
+ {"start": 0, "end": 17, "id": "time-parasite-start", "category": "time", "label": "Паразит времени",
83
+ "message": "Вступление о нынешнем времени ничего не добавляет к предложению.", "fix": "…"},
84
+ {"start": 46, "end": 52, "id": "spelling", "category": "spelling", "label": "Орфография",
85
+ "word": "превет", "suggestions": ["привет", "…"], "message": "…", "fix": "…"}
86
+ ]}
87
+ ```
88
+
89
+ Подбор вариантов исправления медленный, примерно 0,3 секунды на слово. Поэтому варианты
90
+ приходят сразу только для первых десяти ошибок, а на всё отводится не больше трёх секунд.
91
+ У остальных ошибок `suggestions: null`: варианты можно запросить отдельно, когда автор
92
+ нажмёт на слово.
93
+
94
+ **`POST /v1/suggest`** — `{"words": ["превет"]}` → `{"suggestions": {"превет": ["привет", …]}}`,
95
+ до 20 слов за раз.
96
+
97
+ **`GET /v1/rules`** — все правила с категориями и объяснениями, например для страницы
98
+ настроек «какие проверки включены».
99
+
100
+ **`GET /health`** — проверка, что сервер работает. Ключ не нужен.
101
+
102
+ Ошибки приходят в виде `{"error": {"code": "…", "message": "…"}}`: 401 — нет ключа,
103
+ 403 — ключ не для этого сайта, 413 — слишком большой текст, 422 — неверные поля,
104
+ 503 — сервер занят (в заголовке `Retry-After` указано, через сколько секунд повторить).
105
+
106
+ ### Настройки
107
+
108
+ | Переменная | По умолчанию | Что задаёт |
109
+ |---|---|---|
110
+ | `REEDACH_KEYS` | — (обязательно) | ключи доступа |
111
+ | `PORT` | `3000` | порт |
112
+ | `HOST` | `0.0.0.0` | адрес |
113
+ | `REEDACH_MAX_BYTES` | `1000000` | максимальный размер запроса |
114
+ | `REEDACH_CONCURRENCY` | `2` | сколько проверок идёт одновременно |
115
+ | `REEDACH_QUEUE` | `20` | сколько запросов может ждать; остальные получат 503 |
116
+ | `REEDACH_DEMO` | — | `1` — показывать страницу-пример по адресу `/demo/` |
117
+
118
+ ## Как устроена орфография
119
+
120
+ Опечатки ищет [hunspell](https://github.com/hunspell/hunspell) — открытая программа проверки
121
+ орфографии. Reedach запускает её отдельной программой на вашем сервере.
122
+
123
+ Словари: русский — `dictionary-ru` на основе словаря Александра Лебедева, английский —
124
+ `dictionary-en` на основе SCOWL. Оба подключены сразу, поэтому английские слова в русском тексте
125
+ не считаются ошибками. Русскому словарю много лет: новых слов и заимствований в нём может не
126
+ быть, для них есть слова автора.
127
+
128
+ Проверяются слова из букв, в том числе через дефис. Пропускаются ссылки, адреса почты,
129
+ аббревиатуры заглавными буквами, слова с цифрами, однобуквенные слова и английские
130
+ сокращения вроде isn't.
131
+
132
+ Слова автора (`ignore`) принимаются и в других формах: если у слова та же основа и после неё
133
+ не больше пяти букв. У глаголов основа считается без -ть и -ться. Правило работает для слов
134
+ от пяти букв, более короткие должны совпасть точно.
135
+
136
+ Ошибки ищет короткий запуск `hunspell -l` на каждый запрос: это быстро, тысячи слов за
137
+ доли секунды. Варианты подбирает один постоянно запущенный `hunspell -a`, до пяти на слово,
138
+ готовые ответы запоминаются для 5000 слов:
139
+
140
+ - в него уходят только слова из букв, каждое отдельной строкой со служебным знаком в
141
+ начале, поэтому никакой текст не станет командой hunspell;
142
+ - слова автора отсеиваются в Node и в hunspell не добавляются, поэтому между запросами
143
+ ничего не запоминается;
144
+ - слова идут по одному, и ответы разных запросов не перепутаются;
145
+ - зависший процесс убивается и запускается заново, а после 2000 слов процесс
146
+ перезапускается в любом случае.
147
+
148
+ MIT, © 2026 Дима Фаллер. Сторонние словари и программы — в NOTICE.md.
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "reedach-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Сервер Reedach: орфография (hunspell), морфология (Az.js + OpenCorpora) и подсказки по стилю для русского текста — библиотекой или по HTTP",
5
+ "license": "MIT",
6
+ "author": "Dima Faller <dima@dimafaller.ru>",
7
+ "homepage": "https://github.com/dimafaller-en/reedach#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dimafaller-en/reedach.git",
11
+ "directory": "packages/server"
12
+ },
13
+ "bugs": "https://github.com/dimafaller-en/reedach/issues",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./src/index.mjs"
17
+ },
18
+ "bin": {
19
+ "reedach-server": "./src/cli.mjs"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE",
25
+ "NOTICE.md"
26
+ ],
27
+ "keywords": [
28
+ "russian",
29
+ "русский",
30
+ "spelling",
31
+ "hunspell",
32
+ "morphology",
33
+ "style",
34
+ "linter",
35
+ "api"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "dependencies": {
41
+ "az": "0.2.3",
42
+ "dictionary-en": "4.0.0",
43
+ "dictionary-ru": "3.0.0",
44
+ "reedach": "0.1.1"
45
+ },
46
+ "peerDependencies": {
47
+ "reedach-widget": "^0.1.1"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "reedach-widget": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "scripts": {
55
+ "start": "node src/cli.mjs",
56
+ "test": "node --test test/*.test.mjs"
57
+ }
58
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import {checkStyle,sourceBlocks,textBlocks,mergeIssues,RULES,BUILTIN,CATEGORIES} from 'reedach';
2
+ import {morphCheck,MORPH_RULES} from './morph.mjs';
3
+ import {words,known,misspelled,Suggester} from './spell.mjs';
4
+ // One check = spelling (hunspell) + morphology (Az.js) + dictionary style hints (reedach core), merged into one list.
5
+ export const CHECKS=['spelling','morph','style'];
6
+ const LIMITS={blocks:5000,blockChars:20000,suggestWords:10,suggestBudget:3000};
7
+ const bad=message=>Object.assign(new Error(message),{status:422,code:'validation'});
8
+ let shared=null;
9
+ const suggester=()=>shared??=new Suggester();
10
+
11
+ // Blocks from the caller: every UTF-16 unit keeps its own offset (emoji take two).
12
+ const identity=b=>({text:b.text,kind:['h','li'].includes(b.kind)?b.kind:'p',map:Array.from({length:b.text.length},(_,i)=>i)});
13
+
14
+ // input: {markdown | text | blocks:[{text,kind}], ignore:[own words], disable:[rule ids or categories], checks:[...]}
15
+ // Markdown and text hints point into the source string; block hints carry `block` and point into that block's text.
16
+ export async function check(input,{suggester:sg=suggester(),limits={}}={}){
17
+ const lim={...LIMITS,...limits},{markdown,text,blocks:raw}=input??{};
18
+ let blocks,blocksInput=false;
19
+ if(typeof markdown==='string')blocks=sourceBlocks(markdown);
20
+ else if(typeof text==='string')blocks=textBlocks(text);
21
+ else if(Array.isArray(raw)){blocksInput=true;if(raw.length>lim.blocks)throw bad('Слишком много блоков');blocks=raw.map(b=>{if(typeof b?.text!=='string')throw bad('У каждого блока должен быть текст');if(b.text.length>lim.blockChars)throw bad('Слишком длинный блок');return identity(b);});}
22
+ else throw bad('Передайте markdown, text или blocks');
23
+ const list=(v,name)=>{if(v==null)return [];if(!Array.isArray(v)||v.some(x=>typeof x!=='string'))throw bad('Поле '+name+' должно быть списком строк');return v.slice(0,5000);};
24
+ const ignore=list(input.ignore,'ignore'),disable=new Set(list(input.disable,'disable')),checks=new Set(input.checks==null?CHECKS:list(input.checks,'checks'));
25
+ const [spelling,morph]=await Promise.all([
26
+ checks.has('spelling')?findSpelling(blocks,ignore,sg,lim):[],
27
+ checks.has('morph')?morphCheck(blocks).catch(()=>[]):[],
28
+ ]);
29
+ const local=checks.has('style')?checkStyle(blocks):[];
30
+ const issues=mergeIssues(blocks,{spelling,style:morph,local}).filter(it=>!disable.has(it.id)&&!disable.has(it.category));
31
+ return {issues:blocksInput?issues.sort((a,b)=>a.block-b.block||a.start-b.start):issues.map(({block,...it})=>it)};
32
+ }
33
+
34
+ // Misspelled words minus own words; suggestions for the first few within a time budget, the rest get null (ask /v1/suggest).
35
+ async function findSpelling(blocks,ignore,sg,lim){
36
+ const found=(await misspelled(words(blocks.map(b=>b.text).join('\n')))).filter(w=>!known(w,ignore));
37
+ const result=found.map(word=>({word,suggestions:null}));
38
+ const deadline=new Promise(resolve=>setTimeout(resolve,lim.suggestBudget).unref());
39
+ await Promise.race([deadline,Promise.all(result.slice(0,lim.suggestWords).map(item=>sg.suggest(item.word).then(s=>{item.suggestions=s;},()=>{})))]);
40
+ return result.map(item=>({...item,suggestions:item.suggestions&&[...item.suggestions]}));
41
+ }
42
+
43
+ export async function suggest(list,{suggester:sg=suggester()}={}){
44
+ if(!Array.isArray(list)||!list.length||list.length>20||list.some(w=>typeof w!=='string'))throw bad('Передайте от 1 до 20 слов');
45
+ const out={};for(const w of list)out[w]=await sg.suggest(w).catch(e=>{if(e.code==='busy')throw e;return [];});
46
+ return {suggestions:out};
47
+ }
48
+
49
+ // Rules for settings screens: id, category and the explanation, so a site can let authors switch checks off.
50
+ export function rules(){
51
+ const pick=(id,r)=>({id,category:r.category,label:r.label||CATEGORIES[r.category]||r.category,message:r.message,fix:r.fix});
52
+ return {categories:{...CATEGORIES,spelling:'Орфография'},rules:[...RULES.map(r=>pick(r.id,r)),...Object.entries(BUILTIN).map(([id,r])=>pick(id,r)),...Object.entries(MORPH_RULES).map(([id,r])=>pick(id,r))]};
53
+ }
54
+
55
+ export function closeChecks(){shared?.close();shared=null;}
package/src/cli.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import {createServer,parseKeys} from './http.mjs';
3
+ import {closeChecks} from './check.mjs';
4
+ // Settings come from the environment; without a key the server refuses to start.
5
+ const env=process.env,num=(name,fallback)=>{const v=Number(env[name]);return Number.isFinite(v)&&v>0?v:fallback;};
6
+ let keys,server;
7
+ try{
8
+ keys=parseKeys(env.REEDACH_KEYS);if(!keys.length)throw new Error('Задайте REEDACH_KEYS: ключ=https://ваш-сайт.ru; второй-ключ');
9
+ server=createServer({keys,maxBytes:num('REEDACH_MAX_BYTES',1_000_000),concurrency:num('REEDACH_CONCURRENCY',2),queue:num('REEDACH_QUEUE',20),demo:env.REEDACH_DEMO==='1'});
10
+ }catch(e){console.error('reedach: '+e.message);process.exit(1);}
11
+ const port=num('PORT',3000),host=env.HOST||'0.0.0.0';
12
+ server.listen(port,host,()=>console.log('reedach: слушаю '+host+':'+port+', ключей: '+keys.length+(env.REEDACH_DEMO==='1'?', пример: /demo/':'')));
13
+ const stop=()=>{server.close(()=>process.exit(0));closeChecks();setTimeout(()=>process.exit(0),5000).unref();};
14
+ process.on('SIGTERM',stop);process.on('SIGINT',stop);
package/src/http.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import http from 'node:http';
2
+ import {readFileSync} from 'node:fs';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {createHash,timingSafeEqual} from 'node:crypto';
5
+ import {check,suggest,rules} from './check.mjs';
6
+ // HTTP API. Every /v1 call needs a key. A key may list the sites whose pages can call it from the browser;
7
+ // calls without an Origin header (from a site's own server) work with any valid key.
8
+ // The server keeps nothing: texts are not stored and not logged.
9
+
10
+ // "key1=https://a.ru,https://www.a.ru; key2" → [{key, origins}]. A key may itself contain "=" (base64):
11
+ // the sites start only at "=http://" or "=https://".
12
+ const origin=o=>{try{return new URL(o).origin;}catch{throw new Error('Неверный адрес сайта: '+o);}};
13
+ export function parseKeys(value){
14
+ const keys=String(value??'').split(/[;\n]/).map(s=>s.trim()).filter(Boolean).map(entry=>{
15
+ const m=/^(.*?)=(https?:\/\/.*)$/i.exec(entry),key=(m?m[1]:entry).trim();
16
+ return {key,origins:m?m[2].split(/[,\s]+/).filter(Boolean).map(origin):[]};
17
+ });
18
+ for(const k of keys)if(k.key.length<16)throw new Error('Ключ доступа должен быть не короче 16 символов');
19
+ if(new Set(keys.map(k=>k.key)).size<keys.length)throw new Error('Один и тот же ключ указан дважды');
20
+ return keys;
21
+ }
22
+ const digest=s=>createHash('sha256').update(s).digest();
23
+ const fail=(status,code,message)=>Object.assign(new Error(message),{status,code});
24
+
25
+ // Demo page from reedach-widget: four fixed files read once, nothing else is served.
26
+ function demoFiles(){
27
+ let base;try{base=import.meta.resolve('reedach-widget/package.json');}catch{throw new Error('Для страницы-примера установите пакет reedach-widget');}
28
+ const file=p=>fileURLToPath(new URL(p,base));
29
+ const types={html:'text/html; charset=utf-8',css:'text/css; charset=utf-8',js:'text/javascript; charset=utf-8'};
30
+ const map={'/demo/':'demo/index.html','/demo/demo.css':'demo/demo.css','/demo/demo.js':'demo/demo.js','/demo/reedach.min.js':'dist/reedach.min.js'};
31
+ return new Map(Object.entries(map).map(([url,p])=>[url,{body:readFileSync(file(p)),type:types[p.split('.').pop()]}]));
32
+ }
33
+
34
+ export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,demo=false}={}){
35
+ if(!keys?.length)throw new Error('Нужен хотя бы один ключ доступа');
36
+ const pages=demo?demoFiles():null;
37
+ const entries=keys.map(k=>({...k,hash:digest(k.key)})),siteOrigins=new Set(entries.flatMap(k=>k.origins));
38
+ const find=token=>{const h=digest(token);let hit=null;for(const k of entries)if(timingSafeEqual(h,k.hash))hit=k;return hit;};
39
+ let active=0;const waiting=[];
40
+ const slot=()=>{if(active<concurrency){active++;return Promise.resolve();}if(waiting.length>=queue)throw fail(503,'busy','Сервер занят, попробуйте позже');return new Promise(resolve=>waiting.push(resolve));};
41
+ const release=()=>{const next=waiting.shift();if(next)next();else active--;};
42
+
43
+ const server=http.createServer(async(req,res)=>{
44
+ const origin=req.headers.origin,headers={'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'};
45
+ const send=(status,body,extra={})=>{if(res.headersSent)return;res.writeHead(status,{...headers,...extra});res.end(JSON.stringify(body));};
46
+ try{
47
+ const url=new URL(req.url,'http://localhost');
48
+ if(url.pathname==='/health'&&req.method==='GET')return send(200,{ok:true});
49
+ if(pages&&req.method==='GET'&&(url.pathname==='/demo'||pages.has(url.pathname))){
50
+ if(url.pathname==='/demo'){res.writeHead(308,{location:'/demo/'});return res.end();}
51
+ const page=pages.get(url.pathname);
52
+ res.writeHead(200,{'content-type':page.type,'cache-control':'no-cache','x-content-type-options':'nosniff','content-security-policy':"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"});
53
+ return res.end(page.body);
54
+ }
55
+ if(req.method==='OPTIONS'){
56
+ if(!origin||!siteOrigins.has(origin))return send(403,{error:{code:'origin',message:'Этому сайту доступ не разрешён'}});
57
+ res.writeHead(204,{'access-control-allow-origin':origin,'access-control-allow-methods':'GET, POST','access-control-allow-headers':'authorization, content-type','access-control-max-age':'600',vary:'Origin'});return res.end();
58
+ }
59
+ const auth=/^Bearer\s+(\S+)$/i.exec(req.headers.authorization||''),key=auth&&find(auth[1]);
60
+ if(origin&&siteOrigins.has(origin))Object.assign(headers,{'access-control-allow-origin':origin,vary:'Origin'});
61
+ if(!key)throw fail(401,'unauthorized','Нужен ключ доступа');
62
+ if(origin&&!key.origins.includes(origin))throw fail(403,'origin','Этот ключ не работает на этом сайте');
63
+ if(url.pathname==='/v1/rules'&&req.method==='GET')return send(200,rules());
64
+ const route=url.pathname==='/v1/check'?b=>check(b):url.pathname==='/v1/suggest'?b=>suggest(b?.words):null;
65
+ if(!route)throw fail(404,'not_found','Нет такого адреса');
66
+ if(req.method!=='POST')throw fail(405,'method','Используйте POST');
67
+ if(!/^application\/json\b/i.test(req.headers['content-type']||''))throw fail(415,'content_type','Нужен JSON');
68
+ const body=await readJson(req,maxBytes);
69
+ await slot();
70
+ try{return send(200,await route(body));}finally{release();}
71
+ }catch(e){
72
+ const status=e.status||500;
73
+ if(status>=500&&e.code!=='busy'&&!e.code?.startsWith('spell_'))console.error('reedach:',e.code||e.name);
74
+ send(status,{error:{code:e.code||'internal',message:status===500?'Внутренняя ошибка':e.message}},e.code==='busy'?{'retry-after':'5'}:status===413?{connection:'close'}:{});
75
+ }
76
+ });
77
+ server.requestTimeout=30000;server.headersTimeout=10000;
78
+ return server;
79
+ }
80
+
81
+ function readJson(req,maxBytes){
82
+ return new Promise((resolve,reject)=>{
83
+ if(Number(req.headers['content-length'])>maxBytes)return reject(fail(413,'too_large','Слишком большой текст'));
84
+ const chunks=[];let size=0;
85
+ req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){chunks.length=0;reject(fail(413,'too_large','Слишком большой текст'));}else chunks.push(chunk);});
86
+ req.on('end',()=>{if(size>maxBytes)return;try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));}catch{reject(fail(400,'bad_json','Неверный JSON'));}});
87
+ req.on('error',reject);
88
+ });
89
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // Reedach server as a library: call check() in your own process, or start the HTTP API with createServer().
2
+ export {check,suggest,rules,closeChecks,CHECKS} from './check.mjs';
3
+ export {createServer,parseKeys} from './http.mjs';
4
+ export {morphCheck} from './morph.mjs';
5
+ export {words,known,misspelled,Suggester} from './spell.mjs';
package/src/morph.mjs ADDED
@@ -0,0 +1,59 @@
1
+ import {createRequire} from 'node:module';
2
+ import path from 'node:path';
3
+ // Style hints that need parts of speech: Az.js with the OpenCorpora dictionaries, loaded once on first use.
4
+ const require=createRequire(import.meta.url),Az=require('az'),DICTS=path.join(path.dirname(require.resolve('az/package.json')),'dicts');
5
+ let ready=null;
6
+ const init=()=>ready??=new Promise((resolve,reject)=>Az.Morph.init(DICTS,error=>error?reject(error):resolve()));
7
+ const cache=new Map();
8
+ function parse(word){
9
+ const key=word.toLowerCase();let hit=cache.get(key);
10
+ if(!hit){let p;try{p=Az.Morph(key)[0];}catch{}hit={pos:p?.tag?.POS||'',cas:p?.tag?.CAse||'',lemma:String(p?.normalize()?.word||key),score:p?.score||0};if(cache.size>50000)cache.clear();cache.set(key,hit);}
11
+ return hit;
12
+ }
13
+ const RULES={
14
+ 'gerund-start':{category:'syntax',label:'Синтаксис',message:'Предложение начинается с деепричастного оборота: читателю приходится держать его в голове.',fix:'Начните с того, кто действует: не «Сделав работу, мы ушли», а «Мы сделали работу и ушли».'},
15
+ 'participle-stack':{category:'syntax',label:'Синтаксис',message:'Несколько причастных оборотов в одном предложении утяжеляют его.',fix:'Вынесите один оборот в отдельное предложение.'},
16
+ 'short-passive':{category:'passive',label:'Страдательный залог',message:'Краткое причастие прячет того, кто действует.',fix:'Назовите действующее лицо: не «решение принято», а «мы решили».'},
17
+ 'genitive-chain':{category:'nominalization',label:'Отглагольное',message:'Цепочка существительных в родительном падеже: смысл приходится распутывать.',fix:'Разорвите цепочку глаголом: не «проведение проверки выполнения работ», а «проверили, как сделана работа».'},
18
+ 'verb-chain':{category:'syntax',label:'Синтаксис',message:'Цепочка глаголов размывает действие.',fix:'Оставьте главный глагол: не «хотел бы начать делать», а «начну делать» или «делаю».'},
19
+ 'weak-verb-noun':{category:'bureaucratese',label:'Канцелярит',message:'Пустой глагол плюс отглагольное существительное звучат казённо.',fix:'Замените одним глаголом: не «осуществляем доставку», а «доставляем».'},
20
+ 'long-row':{category:'syntax',label:'Однородные',message:'Длинный ряд однородных слов: читатель не удержит все.',fix:'Оставьте два-три главных или превратите ряд в список.'},
21
+ 'adjective-pile':{category:'adjective',label:'Прилагательное',message:'Несколько прилагательных подряд перегружают фразу.',fix:'Оставьте одно, самое точное, или замените фактом.'},
22
+ 'start-repeat':{category:'repetition',label:'Повтор',message:'Третье предложение подряд начинается с того же слова: текст звучит монотонно.',fix:'Начните одно из предложений иначе или объедините их.'},
23
+ 'lemma-repeat':{category:'repetition',label:'Повтор',message:'Одно и то же слово дважды в предложении.',fix:'Замените повтор местоимением или перестройте фразу.'},
24
+ };
25
+ const WEAK=new Set(['осуществлять','осуществить','производить','произвести','проводить','провести','совершать','совершить','оказывать','оказать','выполнять','выполнить','обеспечивать','обеспечить','предоставлять','предоставить','принимать','принять','давать','дать']);
26
+ const CHAIN_HEAD=new Set(['хотеть','мочь','начать','начинать','стать','пытаться','попытаться','собираться','решить','планировать']);
27
+ const SKIP_REPEAT=new Set(['быть','мочь','это','этот','который','свой','весь','такой','тот','один','год','раз','так','как','что','когда','если','также','тоже','только','очень','уже','еще','ещё','друг','другой','какой','какой-то','каждый','самый','себя','чуть']);
28
+ const NOMINAL=/(?:ние|тие|ство|ция|ка)$/;
29
+ const sentences=text=>[...text.matchAll(/[^.!?…]+(?:[.!?…]+|$)/g)].map(m=>({start:m.index,text:m[0]})).filter(s=>s.text.trim());
30
+ // blocks: [{text, kind}] → issues [{block,start,end,id,category,label,message,fix}]
31
+ export async function morphCheck(blocks){
32
+ await init();
33
+ const issues=[],add=(block,start,end,id)=>issues.push({block,start,end,id,...RULES[id]});
34
+ blocks.forEach((b,i)=>{
35
+ if(typeof b?.text!=='string'||b.kind==='h')return;
36
+ const firsts=[];
37
+ for(const s of sentences(b.text)){
38
+ const words=[...s.text.matchAll(/[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)*/g)].map(m=>({w:m[0],start:s.start+m.index,end:s.start+m.index+m[0].length,...parse(m[0])}));
39
+ if(!words.length)continue;
40
+ firsts.push(words[0]);
41
+ const gap=(a,b)=>s.text.slice(a.end-s.start,b.start-s.start);
42
+ if(words[0].pos==='GRND'){const comma=s.text.indexOf(',',words[0].end-s.start);add(i,words[0].start,comma>0?s.start+comma:words[0].end,'gerund-start');}
43
+ const prtf=words.filter(w=>w.pos==='PRTF');if(prtf.length>=2)add(i,prtf[1].start,prtf[1].end,'participle-stack');
44
+ words.forEach((w,k)=>{
45
+ if(w.pos==='PRTS'&&w.score>=0.5&&!/^(?:был|была|было|были|будет|будут)$/i.test(words[k-1]?.w||''))add(i,w.start,w.end,'short-passive');
46
+ if(w.pos==='VERB'&&WEAK.has(w.lemma)){const n=words.slice(k+1,k+3).find(x=>x.pos==='NOUN');if(n&&NOMINAL.test(n.lemma)&&n.lemma!=='участие'||n&&w.lemma.startsWith('при')&&n.lemma==='участие')add(i,w.start,n.end,'weak-verb-noun');}
47
+ });
48
+ for(let k=0;k<words.length;){let j=k;if(words[k].pos==='NOUN')while(j+1<words.length&&words[j+1].pos==='NOUN'&&words[j+1].cas==='gent'&&/^\s+$/.test(gap(words[j],words[j+1])))j++;if(j-k>=2){add(i,words[k].start,words[j].end,'genitive-chain');k=j+1;}else k++;}
49
+ for(let k=0;k<words.length;k++){const head=words[k];if(!(head.pos==='VERB'&&CHAIN_HEAD.has(head.lemma)))continue;let j=k+1;if(words[j]?.w==='бы')j++;let n=0;while(words[j+n]?.pos==='INFN')n++;if(n>=2){add(i,head.start,words[j+n-1].end,'verb-chain');k=j+n;}}
50
+ for(let k=0;k<words.length;){const pos=words[k].pos;let j=k;if(['VERB','ADJF','NOUN','INFN'].includes(pos))while(j+1<words.length&&words[j+1].pos===pos&&/^\s*(?:,|и|или|,\s*и)\s*$/.test(gap(words[j],words[j+1])))j++;if(j-k>=3){add(i,words[k].start,words[j].end,'long-row');k=j+1;}else k++;}
51
+ for(let k=0;k+2<words.length;k++)if(words[k].pos==='ADJF'&&words[k+1].pos==='ADJF'&&words[k+2].pos==='ADJF'&&/^\s+$/.test(gap(words[k],words[k+1])+gap(words[k+1],words[k+2]).replace(/\S/g,'x'))){add(i,words[k].start,words[k+2].end,'adjective-pile');k+=2;}
52
+ const seen=new Map();for(const w of words){if(w.lemma.length<4||SKIP_REPEAT.has(w.lemma)||!['NOUN','VERB','INFN','ADJF','ADVB'].includes(w.pos))continue;if(seen.has(w.lemma)){add(i,w.start,w.end,'lemma-repeat');}else seen.set(w.lemma,w);}
53
+ }
54
+ // Three sentences in a row opening with the same word ("Проектирую… Проектировал… Проектирую…").
55
+ for(let k=2;k<firsts.length;k++)if(firsts[k].lemma===firsts[k-1].lemma&&firsts[k].lemma===firsts[k-2].lemma&&!['и','а','но'].includes(firsts[k].lemma)){add(i,firsts[k].start,firsts[k].end,'start-repeat');k+=2;}
56
+ });
57
+ return issues;
58
+ }
59
+ export const MORPH_RULES=RULES;
package/src/spell.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {fileURLToPath} from 'node:url';
3
+ // Spelling by the hunspell binary on this server: the text never leaves it.
4
+ // Finding misspelled words is cheap, suggestions are not (~0.3 s per word), so they are two steps:
5
+ // a short-lived `hunspell -l` per request finds the words, a long-lived `hunspell -a` suggests fixes.
6
+ const dict=name=>fileURLToPath(import.meta.resolve(name)).replace(/\.js$/,'');
7
+ export const DEFAULT_DICTS=[dict('dictionary-ru'),dict('dictionary-en')].join(',');
8
+ const LETTERS=/^[A-Za-zА-Яа-яЁё]+(?:-[A-Za-zА-Яа-яЁё]+)*$/;
9
+ const spellError=(code,message)=>Object.assign(new Error(message),{status:503,code});
10
+
11
+ // Words worth checking: letters with inner hyphens; links, e-mails, abbreviations, words with digits
12
+ // and English contractions (isn't, couldn’t) are skipped.
13
+ export function words(text,{limit=20000}={}){
14
+ const seen=new Set(),clean=String(text).replace(/https?:\/\/\S+|www\.\S+|[\w.+-]+@[\w-]+\.[\w.-]+/g,' ');
15
+ for(const [w] of clean.matchAll(/(?<![\w-])[A-Za-zА-Яа-яЁё]+(?:[-'’][A-Za-zА-Яа-яЁё]+)*(?![\w-])/g))if(w.length>1&&!/['’]/.test(w)&&w!==w.toUpperCase()&&seen.size<limit)seen.add(w);
16
+ return [...seen];
17
+ }
18
+
19
+ // Own words: a form is accepted when it shares the stem and differs by a short ending; for verbs the stem drops -ть/-ться.
20
+ const stem=e=>/(?:ться|ть|ти|чь)$/.test(e)?e.replace(/(?:ться|ть|ти|чь)$/,''):e.slice(0,-1);
21
+ export function known(word,entries){const w=word.toLowerCase();return entries.some(entry=>{const e=String(entry).toLowerCase(),s=stem(e);return w===e||e.length>=5&&w.startsWith(s)&&w.length-s.length<=5;});}
22
+
23
+ // One `hunspell -l` per request: it prints the misspelled words and exits when stdin closes.
24
+ export function misspelled(list,{dicts=DEFAULT_DICTS,timeout=10000}={}){
25
+ const input=list.filter(w=>LETTERS.test(w));
26
+ if(!input.length)return Promise.resolve([]);
27
+ return new Promise((resolve,reject)=>{
28
+ const child=spawn('hunspell',['-l','-i','utf-8','-d',dicts],{stdio:['pipe','pipe','ignore']});let out='',timedOut=false;
29
+ const timer=setTimeout(()=>{timedOut=true;child.kill('SIGKILL');},timeout);
30
+ child.stdout.setEncoding('utf8').on('data',chunk=>{out+=chunk;});
31
+ child.on('error',e=>{clearTimeout(timer);reject(Object.assign(spellError('spell_unavailable','Проверка орфографии недоступна'),{cause:e}));});
32
+ child.on('close',code=>{clearTimeout(timer);if(timedOut||code!==0)return reject(spellError('spell_failed','Проверка орфографии не удалась'));resolve([...new Set(out.split('\n').filter(Boolean))]);});
33
+ child.stdin.on('error',()=>{});
34
+ child.stdin.end(input.join('\n')+'\n');
35
+ });
36
+ }
37
+
38
+ // A long-lived `hunspell -a` for suggestions. Safety:
39
+ // - only letter words go in, each as one line behind "^", so text can never become a hunspell command;
40
+ // - own words are never added to hunspell, so nothing is remembered between requests;
41
+ // - one word at a time: hunspell answers every input line with a blank line, so answers cannot mix;
42
+ // - a hung or broken process is killed and started again; it is also recycled after `recycle` words.
43
+ export class Suggester{
44
+ constructor({dicts=DEFAULT_DICTS,wordTimeout=3000,recycle=2000,cacheSize=5000,maxPending=50}={}){Object.assign(this,{dicts,wordTimeout,recycle,cacheSize,maxPending});this.cache=new Map();this.queue=Promise.resolve();this.child=null;this.used=0;this.pending=0;}
45
+ #start(){
46
+ const child=spawn('hunspell',['-a','-i','utf-8','-d',this.dicts],{stdio:['pipe','pipe','ignore']});
47
+ // An idle hunspell must not keep the program alive: a pending answer has its own timer.
48
+ child.unref();child.stdin.unref?.();child.stdout.unref?.();
49
+ const state={child,buf:'',waiting:null,banner:true};
50
+ child.stdout.setEncoding('utf8').on('data',chunk=>{state.buf+=chunk;this.#drain(state);});
51
+ child.on('error',()=>this.#fail(state));child.on('close',()=>this.#fail(state));child.stdin.on('error',()=>this.#fail(state));
52
+ this.child=state;this.used=0;return state;
53
+ }
54
+ #drain(state){
55
+ if(state.banner){const nl=state.buf.indexOf('\n');if(nl<0)return;state.buf=state.buf.slice(nl+1);state.banner=false;}
56
+ for(;;){
57
+ const end=state.buf.startsWith('\n')?0:state.buf.indexOf('\n\n');if(end<0)return;
58
+ const answer=state.buf.slice(0,end);state.buf=state.buf.slice(end?end+2:1);
59
+ // An answer nobody asked for means hunspell and we are out of step: start over.
60
+ const w=state.waiting;if(!w)return this.#fail(state);state.waiting=null;w.resolve(answer);
61
+ }
62
+ }
63
+ #fail(state){if(this.child===state)this.child=null;state.dead=true;state.child.kill('SIGKILL');const w=state.waiting;state.waiting=null;w?.reject(spellError('spell_failed','Подбор вариантов не удался'));}
64
+ #ask(word){
65
+ let state=this.child;if(!state||state.dead||this.used>=this.recycle){if(state)this.#fail(state);state=this.#start();}
66
+ this.used++;
67
+ return new Promise((resolve,reject)=>{
68
+ const timer=setTimeout(()=>this.#fail(state),this.wordTimeout);
69
+ state.waiting={resolve:v=>{clearTimeout(timer);resolve(v);},reject:e=>{clearTimeout(timer);reject(e);}};
70
+ state.child.stdin.write('^'+word+'\n');
71
+ });
72
+ }
73
+ // word → up to five suggestions; correct words and words hunspell cannot fix give [].
74
+ suggest(word){
75
+ if(typeof word!=='string'||word.length>40||!LETTERS.test(word))return Promise.resolve([]);
76
+ if(this.cache.has(word))return Promise.resolve(this.cache.get(word));
77
+ if(this.pending>=this.maxPending)return Promise.reject(Object.assign(spellError('busy','Сервер занят, попробуйте позже'),{status:503}));
78
+ this.pending++;
79
+ const run=this.queue.then(()=>this.#ask(word)).then(answer=>{
80
+ const m=/^& \S+ \d+ \d+: (.*)$/m.exec(answer),list=m?m[1].split(', ').slice(0,5):[];
81
+ if(this.cache.size>=this.cacheSize)this.cache.delete(this.cache.keys().next().value);
82
+ this.cache.set(word,list);return list;
83
+ }).finally(()=>{this.pending--;});
84
+ this.queue=run.catch(()=>{});return run;
85
+ }
86
+ close(){if(this.child)this.#fail(this.child);}
87
+ }