reedach-server 0.1.2 → 0.3.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/README.md CHANGED
@@ -91,6 +91,9 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
91
91
  У остальных ошибок `suggestions: null`: варианты можно запросить отдельно, когда автор
92
92
  нажмёт на слово.
93
93
 
94
+ У подсказок по стилю может быть поле `replace` — варианты замены, уже поставленные в форму
95
+ найденного слова: «релевантных» → `["подходящих", "нужных", "уместных"]`.
96
+
94
97
  **`POST /v1/suggest`** — `{"words": ["превет"]}` → `{"suggestions": {"превет": ["привет", …]}}`,
95
98
  до 20 слов за раз.
96
99
 
@@ -110,9 +113,10 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
110
113
  | `REEDACH_KEYS` | — (обязательно) | ключи доступа |
111
114
  | `PORT` | `3000` | порт |
112
115
  | `HOST` | `0.0.0.0` | адрес |
113
- | `REEDACH_MAX_BYTES` | `1000000` | максимальный размер запроса |
116
+ | `REEDACH_MAX_BYTES` | `200000` | максимальный размер запроса в байтах (200 КБ — около 100 тысяч знаков русского текста) |
114
117
  | `REEDACH_CONCURRENCY` | `2` | сколько проверок идёт одновременно |
115
118
  | `REEDACH_QUEUE` | `20` | сколько запросов может ждать; остальные получат 503 |
119
+ | `REEDACH_TIMEOUT` | `20000` | сколько миллисекунд может идти одна проверка; дольше — 503 |
116
120
  | `REEDACH_DEMO` | — | `1` — показывать страницу-пример по адресу `/demo/` |
117
121
 
118
122
  ## Как устроена орфография
@@ -145,4 +149,6 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
145
149
  - зависший процесс убивается и запускается заново, а после 2000 слов процесс
146
150
  перезапускается в любом случае.
147
151
 
152
+ История версий всех пакетов — в [CHANGELOG.md](https://github.com/dimafaller-en/reedach/blob/main/CHANGELOG.md).
153
+
148
154
  MIT, © 2026 Дима Фаллер. Сторонние словари и программы — в NOTICE.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach-server",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Сервер Reedach: орфография (hunspell), морфология (Az.js + OpenCorpora) и подсказки по стилю для русского текста — библиотекой или по HTTP",
5
5
  "license": "MIT",
6
6
  "author": "Dima Faller <dima@dimafaller.ru>",
@@ -41,10 +41,10 @@
41
41
  "az": "0.2.3",
42
42
  "dictionary-en": "4.0.0",
43
43
  "dictionary-ru": "3.0.0",
44
- "reedach": "0.1.2"
44
+ "reedach": "0.3.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "reedach-widget": "^0.1.2"
47
+ "reedach-widget": "^0.3.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "reedach-widget": {
package/src/check.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import {checkStyle,sourceBlocks,textBlocks,mergeIssues,RULES,BUILTIN,CATEGORIES} from 'reedach';
2
- import {morphCheck,MORPH_RULES} from './morph.mjs';
2
+ import {morphCheck,MORPH_RULES,inflectReplacements} from './morph.mjs';
3
3
  import {words,known,misspelled,Suggester} from './spell.mjs';
4
4
  // One check = spelling (hunspell) + morphology (Az.js) + dictionary style hints (reedach core), merged into one list.
5
5
  export const CHECKS=['spelling','morph','style'];
@@ -13,7 +13,8 @@ const identity=b=>({text:b.text,kind:['h','li'].includes(b.kind)?b.kind:'p',map:
13
13
 
14
14
  // input: {markdown | text | blocks:[{text,kind}], ignore:[own words], disable:[rule ids or categories], checks:[...]}
15
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={}}={}){
16
+ // Options for running elsewhere: `suggester` and `misspelled` replace the hunspell calls (the HTTP server's workers ask its main thread).
17
+ export async function check(input,{suggester:sg=suggester(),misspelled:ms=misspelled,limits={}}={}){
17
18
  const lim={...LIMITS,...limits},{markdown,text,blocks:raw}=input??{};
18
19
  let blocks,blocksInput=false;
19
20
  if(typeof markdown==='string')blocks=sourceBlocks(markdown);
@@ -23,17 +24,17 @@ export async function check(input,{suggester:sg=suggester(),limits={}}={}){
23
24
  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
25
  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
26
  const [spelling,morph]=await Promise.all([
26
- checks.has('spelling')?findSpelling(blocks,ignore,sg,lim):[],
27
+ checks.has('spelling')?findSpelling(blocks,ignore,sg,lim,ms):[],
27
28
  checks.has('morph')?morphCheck(blocks).catch(()=>[]):[],
28
29
  ]);
29
- const local=checks.has('style')?checkStyle(blocks):[];
30
+ const local=checks.has('style')?await inflectReplacements(checkStyle(blocks),blocks).catch(()=>checkStyle(blocks)):[];
30
31
  const issues=mergeIssues(blocks,{spelling,style:morph,local}).filter(it=>!disable.has(it.id)&&!disable.has(it.category));
31
32
  return {issues:blocksInput?issues.sort((a,b)=>a.block-b.block||a.start-b.start):issues.map(({block,...it})=>it)};
32
33
  }
33
34
 
34
35
  // 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));
36
+ async function findSpelling(blocks,ignore,sg,lim,ms){
37
+ const found=(await ms(words(blocks.map(b=>b.text).join('\n')))).filter(w=>!known(w,ignore));
37
38
  const result=found.map(word=>({word,suggestions:null}));
38
39
  const deadline=new Promise(resolve=>setTimeout(resolve,lim.suggestBudget).unref());
39
40
  await Promise.race([deadline,Promise.all(result.slice(0,lim.suggestWords).map(item=>sg.suggest(item.word).then(s=>{item.suggestions=s;},()=>{})))]);
@@ -52,4 +53,7 @@ export function rules(){
52
53
  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
  }
54
55
 
56
+ // hunspell for the HTTP server's workers, run in the main thread.
57
+ export const spellHost={misspelled:list=>misspelled(list),suggest:word=>suggester().suggest(word)};
58
+
55
59
  export function closeChecks(){shared?.close();shared=null;}
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@ const env=process.env,num=(name,fallback)=>{const v=Number(env[name]);return Num
6
6
  let keys,server;
7
7
  try{
8
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'});
9
+ server=createServer({keys,maxBytes:num('REEDACH_MAX_BYTES',200_000),concurrency:num('REEDACH_CONCURRENCY',2),queue:num('REEDACH_QUEUE',20),timeout:num('REEDACH_TIMEOUT',20000),demo:env.REEDACH_DEMO==='1'});
10
10
  }catch(e){console.error('reedach: '+e.message);process.exit(1);}
11
11
  const port=num('PORT',3000),host=env.HOST||'0.0.0.0';
12
12
  server.listen(port,host,()=>console.log('reedach: слушаю '+host+':'+port+', ключей: '+keys.length+(env.REEDACH_DEMO==='1'?', пример: /demo/':'')));
package/src/http.mjs CHANGED
@@ -2,7 +2,8 @@ import http from 'node:http';
2
2
  import {readFileSync} from 'node:fs';
3
3
  import {fileURLToPath} from 'node:url';
4
4
  import {createHash,timingSafeEqual} from 'node:crypto';
5
- import {check,suggest,rules} from './check.mjs';
5
+ import {Worker} from 'node:worker_threads';
6
+ import {suggest,rules,spellHost} from './check.mjs';
6
7
  // HTTP API. Every /v1 call needs a key. A key may list the sites whose pages can call it from the browser;
7
8
  // calls without an Origin header (from a site's own server) work with any valid key.
8
9
  // The server keeps nothing: texts are not stored and not logged.
@@ -31,9 +32,35 @@ function demoFiles(){
31
32
  return new Map(Object.entries(map).map(([url,p])=>[url,{body:readFileSync(file(p)),type:types[p.split('.').pop()]}]));
32
33
  }
33
34
 
34
- export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,demo=false}={}){
35
+ // Checks run in worker threads, at most one per slot: the server keeps answering while a long text is checked,
36
+ // and a check running longer than `timeout` ms is stopped together with its worker; the next call starts a fresh one.
37
+ // Workers ask this thread for hunspell (see worker.mjs); an answer to a stopped worker is dropped.
38
+ function workers(timeout){
39
+ const answer=(w,{id,op,arg})=>Promise.resolve().then(()=>op==='suggest'?spellHost.suggest(arg):spellHost.misspelled(arg))
40
+ .then(value=>({id,value}),e=>({id,error:{message:e.message,status:e.status,code:e.code}}))
41
+ .then(reply=>{try{w.postMessage({answer:reply});}catch{}});
42
+ const free=[],all=new Set();
43
+ const spawn=()=>{const w=new Worker(new URL('./worker.mjs',import.meta.url));w.unref();all.add(w);w.once('exit',()=>all.delete(w));return w;};
44
+ return {
45
+ run(input){
46
+ const w=free.pop()||spawn();
47
+ return new Promise((resolve,reject)=>{
48
+ const end=(keep,fn,value)=>{clearTimeout(timer);w.off('message',onMessage).off('error',onError).off('exit',onExit);if(keep)free.push(w);else w.terminate();fn(value);};
49
+ const onMessage=m=>m.ask?answer(w,m.ask):m.error?end(true,reject,Object.assign(new Error(m.error.message),m.error)):end(true,resolve,m.result);
50
+ const onError=e=>end(false,reject,e),onExit=()=>end(false,reject,fail(500,'internal','Проверка прервалась'));
51
+ const timer=setTimeout(()=>end(false,reject,fail(503,'timeout','Текст проверяется слишком долго')),timeout);
52
+ w.on('message',onMessage).on('error',onError).on('exit',onExit);
53
+ // Copying to the worker throws at once on JSON nested thousands deep; the worker stays idle and goes back to the pool.
54
+ try{w.postMessage({input});}catch{end(true,reject,fail(422,'validation','Слишком глубокая вложенность JSON'));}
55
+ });
56
+ },
57
+ close(){for(const w of all)w.terminate();},
58
+ };
59
+ }
60
+
61
+ export function createServer({keys,maxBytes=200_000,concurrency=2,queue=20,timeout=20000,demo=false}={}){
35
62
  if(!keys?.length)throw new Error('Нужен хотя бы один ключ доступа');
36
- const pages=demo?demoFiles():null;
63
+ const pages=demo?demoFiles():null,pool=workers(timeout);
37
64
  const entries=keys.map(k=>({...k,hash:digest(k.key)})),siteOrigins=new Set(entries.flatMap(k=>k.origins));
38
65
  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
66
  let active=0;const waiting=[];
@@ -61,7 +88,7 @@ export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,dem
61
88
  if(!key)throw fail(401,'unauthorized','Нужен ключ доступа');
62
89
  if(origin&&!key.origins.includes(origin))throw fail(403,'origin','Этот ключ не работает на этом сайте');
63
90
  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;
91
+ const route=url.pathname==='/v1/check'?b=>pool.run(b):url.pathname==='/v1/suggest'?b=>suggest(b?.words):null;
65
92
  if(!route)throw fail(404,'not_found','Нет такого адреса');
66
93
  if(req.method!=='POST')throw fail(405,'method','Используйте POST');
67
94
  if(!/^application\/json\b/i.test(req.headers['content-type']||''))throw fail(415,'content_type','Нужен JSON');
@@ -74,7 +101,7 @@ export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,dem
74
101
  send(status,{error:{code:e.code||'internal',message:status===500?'Внутренняя ошибка':e.message}},e.code==='busy'?{'retry-after':'5'}:status===413?{connection:'close'}:{});
75
102
  }
76
103
  });
77
- server.requestTimeout=30000;server.headersTimeout=10000;
104
+ server.requestTimeout=30000;server.headersTimeout=10000;server.on('close',()=>pool.close());
78
105
  return server;
79
106
  }
80
107
 
package/src/morph.mjs CHANGED
@@ -5,9 +5,11 @@ const require=createRequire(import.meta.url),Az=require('az'),DICTS=path.join(pa
5
5
  let ready=null;
6
6
  const init=()=>ready??=new Promise((resolve,reject)=>Az.Morph.init(DICTS,error=>error?reject(error):resolve()));
7
7
  const cache=new Map();
8
+ // Russian words are shorter than 40 letters; Az.js spends seconds on a made-up word of thousands, so those are not parsed.
9
+ const MAX_WORD=40;
8
10
  function parse(word){
9
11
  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);}
12
+ if(!hit){let p;try{if(key.length<=MAX_WORD)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
13
  return hit;
12
14
  }
13
15
  const RULES={
@@ -57,3 +59,32 @@ export async function morphCheck(blocks){
57
59
  return issues;
58
60
  }
59
61
  export const MORPH_RULES=RULES;
62
+
63
+ // A click puts the replacement in place of the found word, so it takes that word's form: «релевантные» → «подходящие».
64
+ // The parse of the found word is the one with the replacement's part of speech («фичи» is a noun here, not a verb);
65
+ // a noun keeps its own gender and, when its base form is plural («возможности»), its number.
66
+ const FAMILY={INFN:'verb',VERB:'verb',ADJF:'adj',PRTF:'adj',ADJS:'adjs',PRTS:'adjs',NOUN:'noun',ADVB:'advb'};
67
+ const KEEP={verb:['NMbr','TEns','PErs','GNdr','MOod','INvl'],adj:['NMbr','CAse','GNdr','ANim'],adjs:['NMbr','GNdr'],noun:['NMbr','CAse'],advb:[]};
68
+ function inflect(found,word){
69
+ const lower=found.toLowerCase();if(lower.length>MAX_WORD||word.length>MAX_WORD)return word;
70
+ let parses,targets;try{parses=Az.Morph(lower);targets=Az.Morph(word);}catch{return word;}
71
+ for(const f of parses){
72
+ const fam=FAMILY[f.tag.POS],t=fam&&targets.find(x=>FAMILY[x.tag.POS]===fam);if(!t)continue;
73
+ if(fam==='advb')return word;
74
+ const keep=KEEP[fam].filter(k=>!(fam==='noun'&&k==='NMbr'&&t.tag.NMbr==='plur'));
75
+ const want=keep.map(k=>f.tag[k]).filter(Boolean);if(fam==='noun'&&t.tag.NMbr==='plur')want.push('plur');
76
+ if(fam==='verb'&&f.tag.POS==='VERB'&&t.tag.POS==='INFN')want.push('VERB');
77
+ if(f.tag.POS==='INFN')return t.normalize()?.word||word;
78
+ try{const r=t.inflect(want);if(r?.word)return r.word;}catch{}
79
+ return word;
80
+ }
81
+ return word;
82
+ }
83
+ // issues from checkStyle on the same blocks: variants (dictionary forms) become replacements in the found word's form.
84
+ export async function inflectReplacements(issues,blocks){
85
+ const todo=issues.filter(it=>it.variants);if(!todo.length)return issues;
86
+ await init();
87
+ // In a phrase («личного бренда», «веб-сайта») the form comes from its last word.
88
+ for(const it of todo){const found=(blocks[it.block]?.text.slice(it.start,it.end)||'').split(/[\s-]+/).pop();if(/^[А-Яа-яЁё]+$/.test(found))it.replace=it.variants.map(w=>inflect(found,w));}
89
+ return issues;
90
+ }
package/src/spell.mjs CHANGED
@@ -11,7 +11,8 @@ const spellError=(code,message)=>Object.assign(new Error(message),{status:503,co
11
11
  // Words worth checking: letters with inner hyphens; links, e-mails, abbreviations, words with digits
12
12
  // and English contractions (isn't, couldn’t) are skipped.
13
13
  export function words(text,{limit=20000}={}){
14
- const seen=new Set(),clean=String(text).replace(/https?:\/\/\S+|www\.\S+|[\w.+-]+@[\w-]+\.[\w.-]+/g,' ');
14
+ // An e-mail starts only where its run of characters starts: otherwise a long word is rescanned from every letter.
15
+ const seen=new Set(),clean=String(text).replace(/https?:\/\/\S+|www\.\S+|(?<![\w.+-])[\w.+-]+@[\w-]+\.[\w.-]+/g,' ');
15
16
  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
17
  return [...seen];
17
18
  }
package/src/worker.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import {parentPort} from 'node:worker_threads';
2
+ import {check} from './check.mjs';
3
+ // One check per message, in a worker thread: a heavy text stalls only this worker, never the HTTP server.
4
+ // hunspell is asked for from the main thread: a process started here would stay a zombie when the worker
5
+ // is stopped on timeout, because only the thread that started a process collects its exit.
6
+ let seq=0;const asked=new Map();
7
+ const ask=(op,arg)=>new Promise((resolve,reject)=>{const id=++seq;asked.set(id,{resolve,reject});parentPort.postMessage({ask:{id,op,arg}});});
8
+ const spell={suggester:{suggest:word=>ask('suggest',word)},misspelled:list=>ask('misspelled',list)};
9
+ parentPort.on('message',async m=>{
10
+ if(m.answer){
11
+ const {id,value,error}=m.answer,wait=asked.get(id);asked.delete(id);
12
+ if(error)wait?.reject(Object.assign(new Error(error.message),error));else wait?.resolve(value);
13
+ return;
14
+ }
15
+ try{parentPort.postMessage({result:await check(m.input,spell)});}
16
+ catch(e){parentPort.postMessage({error:{message:e.message,status:e.status,code:e.code}});}
17
+ });