@seojin08/skill-finder 0.3.3 → 0.3.5
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/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/seed-index.json.gz +0 -0
- package/server/src/app.js +3 -0
- package/server/src/bootstrap.js +2 -1
- package/server/src/db/schema.js +3 -0
- package/server/src/db/seed.js +89 -0
- package/server/src/db/store.js +4 -3
- package/server/src/index.js +11 -0
- package/server/src/indexer.js +5 -1
- package/server/src/install/installed.js +95 -25
- package/server/src/recommend/readme.js +14 -2
- package/server/src/routes/favorites.js +55 -10
- package/server/src/routes/install.js +33 -7
- package/server/src/routes/translate.js +35 -0
- package/server/src/search/result.js +9 -1
- package/server/src/security.js +4 -1
- package/server/src/sources/gitTree.js +11 -1
- package/server/src/sources/github.js +17 -2
- package/server/src/tasks.js +45 -5
- package/server/src/translate/models.js +140 -0
- package/server/src/update.js +1 -1
- package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
- package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-zvqzlnfW.js} +2 -2
- package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
- package/web/dist/assets/index-BGfauLB6.css +2 -0
- package/web/dist/assets/index-Bhi_rUeJ.js +66 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/translator-CCFr2ykW.js +2 -0
- package/web/dist/bergamot/bergamot-translator-worker.js +2919 -0
- package/web/dist/bergamot/bergamot-translator-worker.wasm +0 -0
- package/web/dist/bergamot/translator-worker.js +475 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/index-BgL1OaU4.css +0 -2
- package/web/dist/assets/index-Ec2XBK4-.js +0 -66
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import express, { Router } from 'express';
|
|
2
|
+
import { MODEL_LANGS, MODEL_URL_PATH } from "../translate/models.js";
|
|
3
|
+
const langOf = (v) => (MODEL_LANGS.includes(v) ? v : null);
|
|
4
|
+
/** The translation model for browsers without a built-in translator: its state, and the one-time download. */
|
|
5
|
+
export function translateRouter(ctx) {
|
|
6
|
+
const r = Router();
|
|
7
|
+
r.get('/translate/models/:lang', (req, res) => {
|
|
8
|
+
const lang = langOf(req.params.lang);
|
|
9
|
+
if (!lang)
|
|
10
|
+
return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
|
|
11
|
+
res.json(ctx.translateModels.status(lang));
|
|
12
|
+
});
|
|
13
|
+
r.post('/translate/models/:lang', (req, res) => {
|
|
14
|
+
const lang = langOf(req.params.lang);
|
|
15
|
+
if (!lang)
|
|
16
|
+
return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
|
|
17
|
+
res.json(ctx.translateModels.start(lang));
|
|
18
|
+
});
|
|
19
|
+
return r;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The downloaded files and the engine's registry, outside /api: the engine fetches them without the API header.
|
|
23
|
+
* Read only; the registry's addresses carry each file's hash (?v=), so the files are cached for good.
|
|
24
|
+
*/
|
|
25
|
+
export function translateFiles(ctx) {
|
|
26
|
+
const r = Router();
|
|
27
|
+
r.get(`${MODEL_URL_PATH}/index.json`, (_req, res) => {
|
|
28
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
29
|
+
res.json(ctx.translateModels.registry());
|
|
30
|
+
});
|
|
31
|
+
r.use(MODEL_URL_PATH, express.static(ctx.translateModels.root, { index: false, dotfiles: 'deny', maxAge: '30d', immutable: true }));
|
|
32
|
+
// a file that is not there is a 404, not the page's index.html (the engine saw that only as an integrity error)
|
|
33
|
+
r.use(MODEL_URL_PATH, (_req, res) => void res.status(404).end());
|
|
34
|
+
return r;
|
|
35
|
+
}
|
|
@@ -2,7 +2,7 @@ import { RISK_ORDER } from "../analyze/risk.js";
|
|
|
2
2
|
import { planInstall } from "../install/plan.js";
|
|
3
3
|
import { truncate } from "../util/sanitize.js";
|
|
4
4
|
import { categorize } from "./category.js";
|
|
5
|
-
import { trustTier } from "./rank.js";
|
|
5
|
+
import { freshInstalls, freshness, TRUST_SCORE, trustTier } from "./rank.js";
|
|
6
6
|
export const TRUST_LABEL = { official: '공식', partner: '파트너', community: '커뮤니티' };
|
|
7
7
|
export function treeUrl(repo, ref, dir) {
|
|
8
8
|
const base = `https://github.com/${repo}`;
|
|
@@ -81,6 +81,14 @@ export function storeLookup(store) {
|
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
/** An indexed skill as a card outside a search (favorites, installed): no relevance or popularity score. */
|
|
85
|
+
export function indexItem(store, lookup, skill) {
|
|
86
|
+
const plugin = lookup.plugin(skill.plugin_id);
|
|
87
|
+
const source = plugin ? marketplaceSource(skill, plugin) : githubSource(skill);
|
|
88
|
+
const breakdown = { R: 0, P: 0, F: freshness(skill.last_commit_at), T: TRUST_SCORE[trustTier(skill, plugin)] };
|
|
89
|
+
const stars = skill.repo ? (store.getRepo(skill.repo)?.stars ?? null) : null;
|
|
90
|
+
return buildResultItem({ skill, lookup, sources: source ? [source] : [], installs: freshInstalls(skill), stars, breakdown, score: 0 });
|
|
91
|
+
}
|
|
84
92
|
export function buildResultItem(a) {
|
|
85
93
|
const { skill } = a;
|
|
86
94
|
const plugin = a.lookup.plugin(skill.plugin_id);
|
package/server/src/security.js
CHANGED
|
@@ -49,5 +49,8 @@ export function securityMiddleware(opts) {
|
|
|
49
49
|
next();
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
// 'wasm-unsafe-eval': the translation engine is WebAssembly (served from here); it allows compiling WASM, not eval.
|
|
53
|
+
// Its worker takes the policy of its own script's response, but the page has it too for browsers that give workers
|
|
54
|
+
// the page's policy.
|
|
55
|
+
export const CSP = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
|
|
53
56
|
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'";
|
|
@@ -2,6 +2,13 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { run } from "../util/proc.js";
|
|
5
|
+
/**
|
|
6
|
+
* git asked for a login: GitHub answers so for a repo that is deleted or private (credentials are turned off
|
|
7
|
+
* here), so it is gone, not a network hiccup. It used to count as "could not read" in searches and was asked
|
|
8
|
+
* again on every sync (2026-11-03).
|
|
9
|
+
*/
|
|
10
|
+
export class RepoUnreachableError extends Error {
|
|
11
|
+
}
|
|
5
12
|
const REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
6
13
|
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
7
14
|
const REF_RE = /^[A-Za-z0-9._\/-]{1,200}$/;
|
|
@@ -75,8 +82,11 @@ export async function gitTree(repo, sha, workRoot) {
|
|
|
75
82
|
if (init.exitCode !== 0)
|
|
76
83
|
throw new Error(`git init failed: ${init.stderr.trim()}`);
|
|
77
84
|
const fetch = await git(['fetch', '-q', '--depth', '1', '--filter=blob:none', '--no-tags', repoUrl(repo), sha], dir);
|
|
78
|
-
if (fetch.exitCode !== 0)
|
|
85
|
+
if (fetch.exitCode !== 0) {
|
|
86
|
+
if (/could not read Username|Repository not found/i.test(fetch.stderr))
|
|
87
|
+
throw new RepoUnreachableError(`저장소를 찾을 수 없습니다: ${repo}`);
|
|
79
88
|
throw new Error(`git fetch failed: ${fetch.stderr.trim().split('\n')[0] || fetch.exitCode}`);
|
|
89
|
+
}
|
|
80
90
|
if (sha === 'HEAD') {
|
|
81
91
|
const rev = await git(['rev-parse', 'FETCH_HEAD'], dir);
|
|
82
92
|
sha = rev.stdout.trim();
|
|
@@ -27,6 +27,8 @@ function isSafeRepoPath(path) {
|
|
|
27
27
|
}
|
|
28
28
|
/** Raw files (SKILL.md included) are read up to this many bytes; the rest is cut off. */
|
|
29
29
|
export const RAW_MAX_BYTES = 512 * 1024;
|
|
30
|
+
/** First wait after a raw download got 429, doubled on each retry. */
|
|
31
|
+
const RAW_THROTTLE_WAIT_MS = 1000;
|
|
30
32
|
export class GitHub {
|
|
31
33
|
store;
|
|
32
34
|
config;
|
|
@@ -44,6 +46,8 @@ export class GitHub {
|
|
|
44
46
|
refCache = new Map();
|
|
45
47
|
/** raw.githubusercontent.com answers bursts of hundreds of parallel downloads with 429s; keep it civil. */
|
|
46
48
|
rawSlots = new Semaphore(24);
|
|
49
|
+
/** raw.githubusercontent.com said 429: no download starts before this time. */
|
|
50
|
+
rawPausedUntil = 0;
|
|
47
51
|
metaInFlight = new Map();
|
|
48
52
|
constructor(store, config) {
|
|
49
53
|
this.store = store;
|
|
@@ -244,8 +248,19 @@ export class GitHub {
|
|
|
244
248
|
const url = `https://raw.githubusercontent.com/${repo}/${ref}/${path.split('/').map(encodeURIComponent).join('/')}`;
|
|
245
249
|
for (let attempt = 0;; attempt++) {
|
|
246
250
|
try {
|
|
247
|
-
const res = await this.rawSlots.run(() =>
|
|
248
|
-
|
|
251
|
+
const res = await this.rawSlots.run(async () => {
|
|
252
|
+
const pause = this.rawPausedUntil - Date.now();
|
|
253
|
+
if (pause > 0)
|
|
254
|
+
await sleep(pause);
|
|
255
|
+
return fetchText(url, { maxBytes, timeoutMs: 15_000 });
|
|
256
|
+
});
|
|
257
|
+
if (res.status === 429 && attempt < 3) {
|
|
258
|
+
// Throttled: every download waits (1, 2, 4 s), not only this one. Retrying after 0.4 s still failed
|
|
259
|
+
// while a repo with hundreds of skills was read (2026-11-03).
|
|
260
|
+
this.rawPausedUntil = Math.max(this.rawPausedUntil, Date.now() + RAW_THROTTLE_WAIT_MS * 2 ** attempt);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (res.status >= 500 && attempt < 2) {
|
|
249
264
|
await sleep(400 * (attempt + 1));
|
|
250
265
|
continue;
|
|
251
266
|
}
|
package/server/src/tasks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RISK_RULES_VERSION, riskLevel } from "./analyze/risk.js";
|
|
2
2
|
import { skillMdPath } from "./discovery/tree.js";
|
|
3
3
|
import { PluginUnavailableError, recheckRisk } from "./indexer.js";
|
|
4
|
-
import { GRAPHQL_BATCH } from "./sources/github.js";
|
|
4
|
+
import { GitHubError, GRAPHQL_BATCH } from "./sources/github.js";
|
|
5
5
|
import { syncMarketplace } from "./sources/marketplace.js";
|
|
6
6
|
import { collectPopularRepos, popularState } from "./sources/popularRepos.js";
|
|
7
7
|
import { searchActivity } from "./search/activity.js";
|
|
@@ -41,18 +41,25 @@ export function startSync(ctx, marketplace, force = false) {
|
|
|
41
41
|
.listMarketplaces()
|
|
42
42
|
.filter((m) => (marketplace ? m.name === marketplace : m.enabled === 1));
|
|
43
43
|
ctl.setTotal(targets.length);
|
|
44
|
+
const eager = [];
|
|
44
45
|
for (const m of targets) {
|
|
45
46
|
await ctl.checkpoint();
|
|
46
47
|
try {
|
|
47
48
|
await syncMarketplace(ctx.store, ctx.github, m.name, force);
|
|
48
49
|
ctl.tick();
|
|
50
|
+
if (m.index_mode === 'eager')
|
|
51
|
+
eager.push(m.name);
|
|
49
52
|
}
|
|
50
53
|
catch (err) {
|
|
51
54
|
ctl.tick(`${m.name}: ${String(err.message ?? err).slice(0, 200)}`);
|
|
52
|
-
continue;
|
|
53
55
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
}
|
|
57
|
+
// Every list first, then the skills of the marketplace with the fewest plugins to read: Anthropic's own skills
|
|
58
|
+
// (5 plugins) used to wait about 100 s for the 300-plugin official list on a first run (2026-11-03).
|
|
59
|
+
const pending = new Map(eager.map((name) => [name, ctx.store.listPluginsNeedingIndex(name).length]));
|
|
60
|
+
for (const name of eager.sort((a, b) => pending.get(a) - pending.get(b))) {
|
|
61
|
+
await indexPending(ctx, name, ctl);
|
|
62
|
+
ctx.store.clearSearchCache();
|
|
56
63
|
}
|
|
57
64
|
ctx.store.clearSearchCache();
|
|
58
65
|
if (!marketplace && getSettings(ctx.store).autoCollect) {
|
|
@@ -68,6 +75,30 @@ const POPULAR_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
|
68
75
|
export const REPO_META_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
69
76
|
const REPO_META_STALE_MS = 18 * 60 * 60 * 1000;
|
|
70
77
|
let repoMetaRunning = false;
|
|
78
|
+
/** The smallest group asked again in halves; a group this small that still fails waits for the next run. */
|
|
79
|
+
const REPO_META_MIN_SPLIT = 25;
|
|
80
|
+
/** GitHub gave up on the query (502/503/504, usually when it takes too long) or our own 30 s limit ran out. */
|
|
81
|
+
const gaveUp = (err) => (err instanceof GitHubError && [502, 503, 504].includes(err.status)) || err?.name === 'TimeoutError';
|
|
82
|
+
/**
|
|
83
|
+
* One group of repos, asked again in halves when GitHub gives up on it: a hundred lookups in one query can take
|
|
84
|
+
* too long, and one 502 used to end the whole run, leaving the rest stale until the next one 6 hours later
|
|
85
|
+
* (2026-11-03). Other errors (limit reached, network) still end the run.
|
|
86
|
+
*/
|
|
87
|
+
async function refreshRepoGroup(ctx, repos) {
|
|
88
|
+
try {
|
|
89
|
+
return { stored: await ctx.github.refreshRepoMeta(repos), left: 0 };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (!gaveUp(err))
|
|
93
|
+
throw err;
|
|
94
|
+
if (repos.length <= REPO_META_MIN_SPLIT)
|
|
95
|
+
return { stored: 0, left: repos.length, error: err };
|
|
96
|
+
const half = Math.ceil(repos.length / 2);
|
|
97
|
+
const a = await refreshRepoGroup(ctx, repos.slice(0, half));
|
|
98
|
+
const b = await refreshRepoGroup(ctx, repos.slice(half));
|
|
99
|
+
return { stored: a.stored + b.stored, left: a.left + b.left, error: b.error ?? a.error };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
71
102
|
/**
|
|
72
103
|
* Keeps star counts of the repos skills come from current (GraphQL, 100 per call), so a search reads them
|
|
73
104
|
* from the database instead of asking GitHub per repo, and fills repos never fetched (indexed without a
|
|
@@ -80,11 +111,20 @@ export async function refreshRepoMeta(ctx) {
|
|
|
80
111
|
let stored = 0;
|
|
81
112
|
try {
|
|
82
113
|
const repos = ctx.store.listReposToRefresh(new Date(Date.now() - REPO_META_STALE_MS).toISOString(), 10_000);
|
|
114
|
+
let left = 0;
|
|
83
115
|
for (let i = 0; i < repos.length; i += GRAPHQL_BATCH) {
|
|
84
116
|
while (searchActivity.busy())
|
|
85
117
|
await sleep(500);
|
|
86
|
-
|
|
118
|
+
const group = repos.slice(i, i + GRAPHQL_BATCH);
|
|
119
|
+
const r = await refreshRepoGroup(ctx, group);
|
|
120
|
+
stored += r.stored;
|
|
121
|
+
left += r.left;
|
|
122
|
+
// not one piece answered: GitHub is down rather than slow, so stop asking until the next run
|
|
123
|
+
if (r.left === group.length)
|
|
124
|
+
throw r.error;
|
|
87
125
|
}
|
|
126
|
+
if (left)
|
|
127
|
+
console.warn('[repo-meta]', `GitHub가 일부 요청에 답하지 않아 저장소 ${left}개는 다음 갱신 때 다시 묻습니다`);
|
|
88
128
|
}
|
|
89
129
|
catch (err) {
|
|
90
130
|
console.warn('[repo-meta]', String(err.message ?? err).slice(0, 200));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { zstdDecompress } from 'node:zlib';
|
|
7
|
+
export const MODEL_LANGS = ['ko', 'ja'];
|
|
8
|
+
const CDN = 'https://firefox-settings-attachments.cdn.mozilla.net/';
|
|
9
|
+
/**
|
|
10
|
+
* Pinned from Firefox's Remote Settings collection `translations-models-v2` (desktop records, version 3.0), the
|
|
11
|
+
* versions tried with the engine. A newer model may need a newer engine, so they are changed together.
|
|
12
|
+
*/
|
|
13
|
+
export const MODEL_FILES = {
|
|
14
|
+
ko: [
|
|
15
|
+
{ part: 'lex', name: 'lex.50.50.enko.s2t.bin', location: 'main-workspace/translations-models-v2/493336e3-8e7b-4223-86c5-e88846a85d46.zst', size: 2675843, sha256: '7216367b7f524c0ae1f39353e5c4622b230d1c2e47924182a5853418c1e69f64', unpackedSize: 6676356, unpackedSha256: '7734265822ce315c72334ad538e698ab3900ab8e8f04bd43768d0aecad6b6df7' },
|
|
16
|
+
{ part: 'model', name: 'model.enko.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/e4e7e781-bd8e-4dae-9f8e-d0c8a8a5b07b.zst', size: 44942689, sha256: 'ebe3a360c2a007e31df8595bc01a856376c195718ed3c0a27b336eb9b349d313', unpackedSize: 59504955, unpackedSha256: '1c310a79b61b8824b2eb26b045db043d92722f3e66ea06998f7c89f48da9f6bc' },
|
|
17
|
+
{ part: 'vocab', name: 'vocab.enko.spm', location: 'main-workspace/translations-models-v2/ac6a46cf-1cf0-4ea0-b1ad-9fa40d6f6513.zst', size: 595440, sha256: 'dc5b8d46f6dff08950bf36db741f52431a41466cf33f5a105d1c5154586559d2', unpackedSize: 1406926, unpackedSha256: '709bf0e425345e0e92ce41ed84348a1296a2107c92bdb6624404450ffecbd1f9' },
|
|
18
|
+
],
|
|
19
|
+
ja: [
|
|
20
|
+
{ part: 'lex', name: 'lex.50.50.enja.s2t.bin', location: 'main-workspace/translations-models-v2/83e22dc9-40c4-4ac1-8c25-5d3877b5f628.zst', size: 1751977, sha256: '8954d5b63432366992b22ad2aaa11895e2643014835049fe677655540a705915', unpackedSize: 4128360, unpackedSha256: 'edfb7eb47b98a2689b804ab3614c31b24f1257aa54b1154da3d85e1ae8152d9f' },
|
|
21
|
+
{ part: 'trgvocab', name: 'trgvocab.enja.spm', location: 'main-workspace/translations-models-v2/e54d177c-1131-4fbf-90ea-6741a98d6675.zst', size: 361180, sha256: '5384abf34f0e1298f795bd14909a577272059041e60f91a5eb10500e35d0ee98', unpackedSize: 827144, unpackedSha256: '3b3d9f8f3a034d98d0a476f1794fa79c01e4e98a967ceb6777a66ba2d03ec1e1' },
|
|
22
|
+
{ part: 'srcvocab', name: 'srcvocab.enja.spm', location: 'main-workspace/translations-models-v2/d10e4482-b9ba-4565-ab44-5ba3cebcf94b.zst', size: 345747, sha256: 'dd1e93d625d9a8d8a42f75255343f302ea37527933eda1a490652b66a7408a64', unpackedSize: 796275, unpackedSha256: '970c98d174fc01e0339fbabbf45af36a4be3f26f819ec1a5ea1189f71e091889' },
|
|
23
|
+
{ part: 'model', name: 'model.enja.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/a01a5318-41c7-4266-8fe1-66c105d6d711.zst', size: 32431590, sha256: 'f92259b9904812350b71f440c3328f3e2e856f31c1a3ab6002756380dd908e00', unpackedSize: 43849787, unpackedSha256: '59ae659f9bb63e4f81f474fe3c03d3f4499434b5f9e779fab7c12a45f31fd562' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
/** The address the page reads the files from (outside /api: the engine's fetch sends no X-Skill-Finder header). */
|
|
27
|
+
export const MODEL_URL_PATH = '/translate-models';
|
|
28
|
+
const FILE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
29
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
30
|
+
const pairDir = (lang) => `en${lang}`;
|
|
31
|
+
/** Written last, so a half-finished download is never taken for a model. Its content names the pinned files. */
|
|
32
|
+
const READY = 'ready.json';
|
|
33
|
+
export class TranslateModels {
|
|
34
|
+
dir;
|
|
35
|
+
fetchImpl;
|
|
36
|
+
files;
|
|
37
|
+
running = new Map();
|
|
38
|
+
/** `dir`: where the unpacked files live (`<dataDir>/translate-models`), served as {@link MODEL_URL_PATH}. `files`: tests. */
|
|
39
|
+
constructor(dir, fetchImpl = (...a) => fetch(...a), files = MODEL_FILES) {
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
this.fetchImpl = fetchImpl;
|
|
42
|
+
this.files = files;
|
|
43
|
+
}
|
|
44
|
+
get root() {
|
|
45
|
+
return this.dir;
|
|
46
|
+
}
|
|
47
|
+
readyMark(lang) {
|
|
48
|
+
return JSON.stringify(this.files[lang].map((f) => f.unpackedSha256));
|
|
49
|
+
}
|
|
50
|
+
/** The mark, and every file still there at its size (one deleted by hand or quarantined means download again). */
|
|
51
|
+
ready(lang) {
|
|
52
|
+
try {
|
|
53
|
+
const dir = join(this.dir, pairDir(lang));
|
|
54
|
+
return readFileSync(join(dir, READY), 'utf8') === this.readyMark(lang) && this.files[lang].every((f) => statSync(join(dir, f.name)).size === f.unpackedSize);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
status(lang) {
|
|
61
|
+
const run = this.running.get(lang);
|
|
62
|
+
if (run && run.state !== 'ready')
|
|
63
|
+
return { ...run };
|
|
64
|
+
const total = this.files[lang].reduce((n, f) => n + f.size, 0);
|
|
65
|
+
return this.ready(lang) ? { lang, state: 'ready', received: total, total, error: null } : { lang, state: 'absent', received: 0, total, error: null };
|
|
66
|
+
}
|
|
67
|
+
/** Starts the download unless it is there or under way; the returned status is polled with {@link status}. */
|
|
68
|
+
start(lang) {
|
|
69
|
+
const now = this.status(lang);
|
|
70
|
+
if (now.state === 'ready' || now.state === 'downloading')
|
|
71
|
+
return now;
|
|
72
|
+
const run = { lang, state: 'downloading', received: 0, total: now.total, error: null };
|
|
73
|
+
this.running.set(lang, run);
|
|
74
|
+
this.download(lang, run).then(() => (run.state = 'ready'), (err) => {
|
|
75
|
+
run.state = 'error';
|
|
76
|
+
run.error = String(err?.message ?? err).slice(0, 300);
|
|
77
|
+
console.warn('[translate]', run.error);
|
|
78
|
+
});
|
|
79
|
+
return { ...run };
|
|
80
|
+
}
|
|
81
|
+
async download(lang, run) {
|
|
82
|
+
const dir = join(this.dir, pairDir(lang));
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
await rm(join(dir, READY), { force: true });
|
|
85
|
+
for (const f of this.files[lang]) {
|
|
86
|
+
const target = join(dir, f.name);
|
|
87
|
+
if (existsSync(target) && sha256(readFileSync(target)) === f.unpackedSha256) {
|
|
88
|
+
run.received += f.size;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// network failures and the time limit in the app's words (they used to show as English runtime messages)
|
|
92
|
+
const failed = (err) => new Error(`${f.name}: 내려받지 못했습니다 (${err?.name === 'TimeoutError' ? '시간 초과' : '연결 실패'})`);
|
|
93
|
+
const res = await this.fetchImpl(CDN + f.location, { signal: AbortSignal.timeout(FILE_TIMEOUT_MS) }).catch((err) => {
|
|
94
|
+
throw failed(err);
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok || !res.body)
|
|
97
|
+
throw new Error(`${f.name}: 내려받지 못했습니다 (${res.status})`);
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let size = 0;
|
|
100
|
+
try {
|
|
101
|
+
for await (const chunk of res.body) {
|
|
102
|
+
size += chunk.byteLength;
|
|
103
|
+
if (size > f.size)
|
|
104
|
+
throw new Error(`${f.name}: 크기가 맞지 않습니다`);
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
run.received += chunk.byteLength;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw size > f.size ? err : failed(err);
|
|
111
|
+
}
|
|
112
|
+
const packed = Buffer.concat(chunks);
|
|
113
|
+
if (sha256(packed) !== f.sha256)
|
|
114
|
+
throw new Error(`${f.name}: SHA-256이 맞지 않습니다`);
|
|
115
|
+
const unpacked = await promisify(zstdDecompress)(packed);
|
|
116
|
+
if (unpacked.length !== f.unpackedSize || sha256(unpacked) !== f.unpackedSha256)
|
|
117
|
+
throw new Error(`${f.name}: 압축을 푼 파일이 맞지 않습니다`);
|
|
118
|
+
await writeFile(`${target}.part`, unpacked);
|
|
119
|
+
await rename(`${target}.part`, target);
|
|
120
|
+
}
|
|
121
|
+
await writeFile(join(dir, READY), this.readyMark(lang));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The engine's model registry for the languages that are ready: per pair, its files with their addresses and
|
|
125
|
+
* SHA-256 (the engine checks them again with subresource integrity). The address carries the file's hash, so a
|
|
126
|
+
* browser that kept an older model (cached for good) asks for the new one when the pins change.
|
|
127
|
+
*/
|
|
128
|
+
registry() {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const lang of MODEL_LANGS) {
|
|
131
|
+
if (!this.ready(lang))
|
|
132
|
+
continue;
|
|
133
|
+
out[pairDir(lang)] = Object.fromEntries(this.files[lang].map((f) => [
|
|
134
|
+
f.part,
|
|
135
|
+
{ name: `${MODEL_URL_PATH}/${pairDir(lang)}/${f.name}?v=${f.unpackedSha256.slice(0, 16)}`, size: f.unpackedSize, expectedSha256Hash: f.unpackedSha256 },
|
|
136
|
+
]));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
}
|
package/server/src/update.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { releaseMessage, signedNotes, verifyText } from "./release.js";
|
|
2
|
-
const DEFAULT_BASE = 'https://skill-finder.
|
|
2
|
+
const DEFAULT_BASE = 'https://seojin-skill-finder.pages.dev/download/';
|
|
3
3
|
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
4
4
|
let latest = null;
|
|
5
5
|
const parse = (v) => v.split('.').map((n) => Number(n) || 0);
|
|
@@ -73,6 +73,8 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
|
73
73
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
74
74
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
75
75
|
|
|
76
|
+
## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
|
|
77
|
+
|
|
76
78
|
## @phosphor-icons/react - 2.1.10 (MIT)
|
|
77
79
|
|
|
78
80
|
MIT License
|
|
@@ -2265,3 +2267,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
2265
2267
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
2266
2268
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
2267
2269
|
SOFTWARE.
|
|
2270
|
+
|
|
2271
|
+
## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
|
|
2272
|
+
|
|
2273
|
+
The translation engine in bergamot/ is shipped unmodified. Its source (version 0.4.16): https://codeberg.org/mkljczk/bergamot-translator (MPL-2.0, https://mozilla.org/MPL/2.0/). Its WebAssembly is built from bergamot-translator with the libraries it includes, such as Marian NMT (MIT), SentencePiece (Apache-2.0) and intgemm (MIT); their notices are in that source. The translation models are Mozilla's (MPL-2.0), downloaded from Mozilla when turned on.
|