reedach-server 0.0.0-stage → 0.1.0
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 +21 -0
- package/NOTICE.md +26 -0
- package/README.md +125 -2
- package/package.json +56 -4
- package/src/check.mjs +55 -0
- package/src/cli.mjs +14 -0
- package/src/http.mjs +89 -0
- package/src/index.mjs +5 -0
- package/src/morph.mjs +59 -0
- package/src/spell.mjs +87 -0
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,26 @@
|
|
|
1
|
+
# Источники и сторонние лицензии
|
|
2
|
+
|
|
3
|
+
## Словарь правил
|
|
4
|
+
|
|
5
|
+
Правила и формулировки подсказок в `packages/core/src/rules.js` написаны для проекта
|
|
6
|
+
самостоятельно. Идеи взяты из открытых статей об информационном стиле: Максим Ильяхов,
|
|
7
|
+
Бюро Горбунова и другие авторы. Тексты статей в проект не копировались.
|
|
8
|
+
|
|
9
|
+
Проект не связан с сервисом «Главред» и его авторами и не заменяет профессиональную редактуру.
|
|
10
|
+
|
|
11
|
+
## Сторонние данные и программы
|
|
12
|
+
|
|
13
|
+
Ядро (`packages/core`) не содержит сторонних данных и зависимостей.
|
|
14
|
+
|
|
15
|
+
Сервер (`packages/server`) использует их как зависимости, не включая в этот репозиторий:
|
|
16
|
+
|
|
17
|
+
| Компонент | Лицензия | Источник |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Az.js — морфологический анализ | MIT | https://github.com/deNULL/Az.js |
|
|
20
|
+
| Словари Az.js — данные OpenCorpora, сборка pymorphy2 | CC BY-SA | http://opencorpora.org |
|
|
21
|
+
| dictionary-ru — словарь hunspell, © 1997–2008 Alexander I. Lebedev | BSD-3-Clause | https://github.com/wooorm/dictionaries |
|
|
22
|
+
| dictionary-en — словарь hunspell на основе SCOWL | MIT AND BSD | https://github.com/wooorm/dictionaries |
|
|
23
|
+
| hunspell — проверка орфографии, запускается как отдельная программа | GPL-2.0 / LGPL-2.1 / MPL-1.1 на выбор | https://hunspell.github.io |
|
|
24
|
+
|
|
25
|
+
Словари OpenCorpora используются без изменений. Изменённые словари, если появятся,
|
|
26
|
+
будут распространяться под той же лицензией CC BY-SA с указанием источника.
|
package/README.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# reedach-server
|
|
2
2
|
|
|
3
|
-
|
|
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: ['Glaskraft'], // слова автора: формы тоже принимаются
|
|
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_KEYS` — список ключей через `;`. Каждый ключ не короче 16 символов. После `=`
|
|
45
|
+
через запятую перечисляются сайты, со страниц которых этот ключ можно использовать
|
|
46
|
+
прямо из браузера.
|
|
47
|
+
|
|
48
|
+
- **Ключ с сайтами** работает со страниц этих сайтов и с любого сервера.
|
|
49
|
+
- **Ключ без сайтов** работает только с сервера: браузер с ним получит отказ.
|
|
50
|
+
|
|
51
|
+
Ключ в коде страницы виден любому посетителю, секретом он не является. Чужой сайт его
|
|
52
|
+
использовать не сможет, но скрипт вне браузера сможет, а сдержат его только лимиты.
|
|
53
|
+
Надёжнее отправлять запросы через сервер своего сайта и не открывать Reedach в интернет.
|
|
54
|
+
|
|
55
|
+
### Запросы
|
|
56
|
+
|
|
57
|
+
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`, тело запроса — JSON.
|
|
58
|
+
|
|
59
|
+
**`POST /v1/check`** — проверить текст. Передайте одно из трёх:
|
|
60
|
+
|
|
61
|
+
| Поле | Что это | Куда указывают `start` и `end` |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `markdown` | Markdown-текст | в исходный текст |
|
|
64
|
+
| `text` | обычный текст, абзацы через пустую строку | в исходный текст |
|
|
65
|
+
| `blocks` | `[{text, kind}]`, где `kind` — `p`, `li` или `h` | в текст блока номер `block` |
|
|
66
|
+
|
|
67
|
+
Необязательные поля:
|
|
68
|
+
|
|
69
|
+
- `ignore` — слова автора;
|
|
70
|
+
- `disable` — id правил или категории, которые нужно выключить;
|
|
71
|
+
- `checks` — какие проверки запускать: `spelling`, `morph`, `style` (по умолчанию все).
|
|
72
|
+
|
|
73
|
+
Ответ:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"issues": [
|
|
77
|
+
{"start": 0, "end": 17, "id": "time-parasite-start", "category": "time", "label": "Паразит времени",
|
|
78
|
+
"message": "Вступление о нынешнем времени ничего не добавляет к предложению.", "fix": "…"},
|
|
79
|
+
{"start": 46, "end": 52, "id": "spelling", "category": "spelling", "label": "Орфография",
|
|
80
|
+
"word": "превет", "suggestions": ["привет", "…"], "message": "…", "fix": "…"}
|
|
81
|
+
]}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Подбор вариантов исправления медленный, примерно 0,3 секунды на слово. Поэтому варианты
|
|
85
|
+
приходят сразу только для первых десяти ошибок, а на всё отводится не больше трёх секунд.
|
|
86
|
+
У остальных ошибок `suggestions: null`: варианты можно запросить отдельно, когда автор
|
|
87
|
+
нажмёт на слово.
|
|
88
|
+
|
|
89
|
+
**`POST /v1/suggest`** — `{"words": ["превет"]}` → `{"suggestions": {"превет": ["привет", …]}}`,
|
|
90
|
+
до 20 слов за раз.
|
|
91
|
+
|
|
92
|
+
**`GET /v1/rules`** — все правила с категориями и объяснениями, например для страницы
|
|
93
|
+
настроек «какие проверки включены».
|
|
94
|
+
|
|
95
|
+
**`GET /health`** — проверка, что сервер работает. Ключ не нужен.
|
|
96
|
+
|
|
97
|
+
Ошибки приходят в виде `{"error": {"code": "…", "message": "…"}}`: 401 — нет ключа,
|
|
98
|
+
403 — ключ не для этого сайта, 413 — слишком большой текст, 422 — неверные поля,
|
|
99
|
+
503 — сервер занят (в заголовке `Retry-After` указано, через сколько секунд повторить).
|
|
100
|
+
|
|
101
|
+
### Настройки
|
|
102
|
+
|
|
103
|
+
| Переменная | По умолчанию | Что задаёт |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `REEDACH_KEYS` | — (обязательно) | ключи доступа |
|
|
106
|
+
| `PORT` | `3000` | порт |
|
|
107
|
+
| `HOST` | `0.0.0.0` | адрес |
|
|
108
|
+
| `REEDACH_MAX_BYTES` | `1000000` | максимальный размер запроса |
|
|
109
|
+
| `REEDACH_CONCURRENCY` | `2` | сколько проверок идёт одновременно |
|
|
110
|
+
| `REEDACH_QUEUE` | `20` | сколько запросов может ждать; остальные получат 503 |
|
|
111
|
+
| `REEDACH_DEMO` | — | `1` — показывать страницу-пример по адресу `/demo/` |
|
|
112
|
+
|
|
113
|
+
## Как устроена орфография
|
|
114
|
+
|
|
115
|
+
Ошибки ищет короткий запуск `hunspell -l` на каждый запрос: это быстро, тысячи слов за
|
|
116
|
+
доли секунды. Варианты подбирает один постоянно запущенный `hunspell -a`:
|
|
117
|
+
|
|
118
|
+
- в него уходят только слова из букв, каждое отдельной строкой со служебным знаком в
|
|
119
|
+
начале, поэтому никакой текст не станет командой hunspell;
|
|
120
|
+
- слова автора отсеиваются в Node и в hunspell не добавляются, поэтому между запросами
|
|
121
|
+
ничего не запоминается;
|
|
122
|
+
- слова идут по одному, и ответы разных запросов не перепутаются;
|
|
123
|
+
- зависший процесс убивается и запускается заново, а после 2000 слов процесс
|
|
124
|
+
перезапускается в любом случае.
|
|
125
|
+
|
|
126
|
+
MIT, © 2026 Дима Фаллер. Сторонние словари и программы — в NOTICE.md.
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reedach-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"reedach-widget": "^0.1.0"
|
|
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
|
+
}
|