diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..b91095ac
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,13 @@
+{
+ "editor.tabSize": 2,
+ "json.validate.enable": false,
+ "[javascript]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.maxTokenizationLineLength": 2500,
+ },
+ "[vue]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ },
+}
\ No newline at end of file
diff --git a/docs/みんなうっかり間違える!?中国語_様態補語と程度補語の文法の注意点とは.md b/docs/みんなうっかり間違える!?中国語_様態補語と程度補語の文法の注意点とは.md
index 38dd98e9..79e75177 100644
--- a/docs/みんなうっかり間違える!?中国語_様態補語と程度補語の文法の注意点とは.md
+++ b/docs/みんなうっかり間違える!?中国語_様態補語と程度補語の文法の注意点とは.md
@@ -1,5 +1,6 @@
---
interval: 2
+dedup: true
video: '/みんなうっかり間違える!?中国語 様態補語と程度補語の文法の注意点とは [m15ta7yPgjw].mp4'
---
@@ -16,7 +17,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+0:14
+

+
+
@@ -28,7 +34,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+0:26
+

+
+
@@ -44,6 +55,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+1:04
+

@@ -89,6 +103,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -96,7 +112,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+1:34
+

+
+
@@ -104,11 +125,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+1:38
+


+
+
@@ -120,6 +146,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+1:46
+

@@ -145,6 +174,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -152,11 +183,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+2:02
+


+
+
@@ -168,6 +204,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+2:10
+

@@ -177,6 +216,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -184,6 +225,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+2:18
+

@@ -197,6 +241,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -208,6 +254,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+2:30
+

@@ -269,6 +318,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -276,6 +327,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+3:04
+

@@ -285,6 +339,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -292,11 +348,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+3:12
+


+
+
@@ -308,6 +369,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+3:20
+

@@ -341,6 +405,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -348,11 +414,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+3:40
+


+
+
@@ -364,6 +435,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+3:48
+

@@ -393,6 +467,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -400,11 +476,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:06
+


+
+
@@ -412,7 +493,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:12
+

+
+
@@ -420,6 +506,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:16
+

@@ -437,6 +526,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -448,6 +539,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:30
+

@@ -457,6 +551,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -464,6 +560,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:38
+

@@ -485,6 +584,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -492,6 +593,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+4:52
+

@@ -517,6 +621,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -524,6 +630,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+5:08
+

@@ -585,6 +694,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -592,7 +703,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+5:42
+

+
+
@@ -600,6 +716,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+5:46
+

@@ -621,6 +740,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -628,6 +749,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+6:00
+

@@ -649,6 +773,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -660,6 +786,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+6:16
+

@@ -673,6 +802,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -680,6 +811,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+6:26
+

@@ -717,6 +851,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -724,6 +860,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+6:48
+

@@ -785,6 +924,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -796,6 +937,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+7:24
+

@@ -829,6 +973,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -836,6 +982,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+7:44
+

@@ -853,6 +1002,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -860,7 +1011,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+7:56
+

+
+
@@ -868,6 +1024,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:00
+

@@ -897,6 +1056,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -904,6 +1065,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:18
+

@@ -913,6 +1077,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -920,7 +1086,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:26
+

+
+
@@ -928,11 +1099,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:30
+


+
+
@@ -944,6 +1120,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:38
+

@@ -969,6 +1148,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -976,11 +1157,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+8:54
+


+
+
@@ -988,7 +1174,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:00
+

+
+
@@ -996,6 +1187,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:04
+

@@ -1017,6 +1211,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1028,11 +1224,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:20
+


+
+
@@ -1040,7 +1241,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:26
+

+
+
@@ -1048,6 +1254,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:30
+

@@ -1065,6 +1274,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1072,11 +1283,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:42
+


+
+
@@ -1084,7 +1300,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:48
+

+
+
@@ -1092,6 +1313,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+9:52
+

@@ -1109,6 +1333,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1116,7 +1342,12 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+10:04
+

+
+
@@ -1124,11 +1355,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+10:08
+


+
+
@@ -1140,6 +1376,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+10:16
+

@@ -1177,6 +1416,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1188,11 +1429,16 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+10:40
+


+
+
@@ -1208,6 +1454,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+10:50
+

@@ -1237,6 +1486,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1248,6 +1499,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+11:10
+

@@ -1269,6 +1523,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1276,6 +1532,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+11:24
+

@@ -1285,6 +1544,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1296,6 +1557,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+11:34
+

@@ -1313,6 +1577,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1320,6 +1586,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+11:46
+

@@ -1329,6 +1598,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1336,6 +1607,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+11:54
+

@@ -1345,6 +1619,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1352,6 +1628,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+12:02
+

@@ -1373,6 +1652,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1384,6 +1665,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+12:18
+

@@ -1409,6 +1693,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1420,6 +1706,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+12:36
+

@@ -1445,6 +1734,8 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
@@ -1452,6 +1743,9 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度
+
+12:52
+

@@ -1469,5 +1763,7 @@ video: '/みんなうっかり間違える!?中国語 様態補語と程度

+
+
diff --git a/group-similar.js b/group-similar.js
new file mode 100644
index 00000000..825dd301
--- /dev/null
+++ b/group-similar.js
@@ -0,0 +1,321 @@
+// @ts-check
+/**
+ * group_similar.js
+ *
+ * Reads a markdown file containing timestamped screenshot references like:
+ * 
+ * 
+ * ...
+ *
+ * Consecutive images that are visually similar get collapsed into a
+ * block anchored under the first (non-similar) image.
+ *
+ * Usage:
+ * node group_similar.js [output.md] [--threshold 0.005]
+ *
+ * - output.md defaults to input.md (overwrites in place)
+ * - --threshold: similarity cutoff 0-1. Images with a diff ratio
+ * BELOW this value are considered "similar". Default: 0.005 (0.5%).
+ *
+ * Dependencies:
+ * npm install sharp
+ * npm install --save-dev @types/node (for //@ts-check)
+ */
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+// @ts-ignore - sharp ships no bundled .d.ts; types are not needed at runtime
+import sharp from "sharp";
+
+// ---------------------------------------------------------------------------
+// Types (JSDoc only — kept in sync with every function signature)
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {{ type: "image", alt: string, src: string, raw: string }} ImageNode
+ * @typedef {{ type: "text", raw: string }} TextNode
+ * @typedef {ImageNode | TextNode} Line
+ * @typedef {{ blanks: TextNode[], image: ImageNode }} Child
+ * @typedef {{ type: "group", anchor: ImageNode, children: Child[] }} GroupNode
+ * @typedef {Line | GroupNode} OutputNode
+ * @typedef {{ comparisons: number, grouped: number }} Stats
+ */
+
+// ---------------------------------------------------------------------------
+// Image comparison (returns 0 = identical … 1 = completely different)
+// ---------------------------------------------------------------------------
+
+const COMPARE_WIDTH = 64;
+const COMPARE_HEIGHT = 64;
+
+/**
+ * @param {string} imgPath
+ * @returns {Promise}
+ */
+async function loadSmall(imgPath) {
+ return sharp(imgPath)
+ .resize(COMPARE_WIDTH, COMPARE_HEIGHT, { fit: "cover" })
+ .raw()
+ .toBuffer();
+}
+
+/**
+ * Mean squared error normalised to [0, 1].
+ * @param {Buffer} bufA
+ * @param {Buffer} bufB
+ * @returns {number}
+ */
+function mse(bufA, bufB) {
+ if (bufA.length !== bufB.length) return 1;
+ let sum = 0;
+ for (let i = 0; i < bufA.length; i++) {
+ const d = bufA[i] - bufB[i];
+ sum += d * d;
+ }
+ return sum / bufA.length / (255 * 255);
+}
+
+/**
+ * @param {string} pathA
+ * @param {string} pathB
+ * @param {number} threshold
+ * @returns {Promise<{ similar: boolean, score: number }>}
+ */
+async function compare(pathA, pathB, threshold) {
+ const [a, b] = await Promise.all([loadSmall(pathA), loadSmall(pathB)]);
+ const score = mse(a, b);
+ return { similar: score < threshold, score };
+}
+
+// ---------------------------------------------------------------------------
+// Markdown parsing
+// ---------------------------------------------------------------------------
+
+const IMG_RE = /^!\[([^\]]*)\]\(([^)]+)\)\s*$/;
+
+/**
+ * @param {string} line
+ * @returns {Line}
+ */
+function parseLine(line) {
+ const m = line.match(IMG_RE);
+ if (m) return { type: "image", alt: m[1], src: m[2], raw: line };
+ return { type: "text", raw: line };
+}
+
+/**
+ * @param {Line} node
+ * @returns {boolean}
+ */
+function isBlank(node) {
+ return node.type === "text" && node.raw.trim() === "";
+}
+
+// ---------------------------------------------------------------------------
+// Core grouping logic
+// ---------------------------------------------------------------------------
+
+/**
+ * Walk parsed lines and group consecutive similar images.
+ * Blank lines between similar images travel with the group; non-blank
+ * non-image text always breaks the lookahead.
+ *
+ * @param {Line[]} lines
+ * @param {string} baseDir
+ * @param {number} threshold
+ * @param {Stats} stats - mutated in place for the caller's summary
+ * @returns {Promise}
+ */
+async function groupImages(lines, baseDir, threshold, stats) {
+ /** @type {OutputNode[]} */
+ const result = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (line.type !== "image") {
+ result.push(line);
+ i++;
+ continue;
+ }
+
+ /** @type {ImageNode} */
+ const anchor = line;
+ /** @type {Child[]} */
+ const children = [];
+ let j = i + 1;
+
+ // lookahead
+ while (j < lines.length) {
+ /** @type {TextNode[]} */
+ const blanks = [];
+ while (j < lines.length && isBlank(lines[j])) {
+ // isBlank already guarantees type === "text"
+ blanks.push(/** @type {TextNode} */ (lines[j]));
+ j++;
+ }
+
+ if (j >= lines.length || lines[j].type !== "image") {
+ j -= blanks.length; // rewind - blanks stay outside
+ break;
+ }
+
+ /** @type {ImageNode} */
+ const candidate = /** @type {ImageNode} */ (lines[j]);
+ const { similar } = await compare(
+ resolve(baseDir, anchor.src),
+ resolve(baseDir, candidate.src),
+ threshold,
+ );
+ stats.comparisons++;
+
+ if (similar) {
+ stats.grouped++;
+ children.push({ blanks, image: candidate });
+ j++;
+ } else {
+ j -= blanks.length; // rewind - blanks stay outside
+ break;
+ }
+ }
+
+ if (children.length > 0) {
+ result.push({ type: "group", anchor, children });
+ } else {
+ result.push(anchor);
+ }
+
+ i = j;
+ }
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+/**
+ * @param {ImageNode} node
+ * @returns {string}
+ */
+function renderImage(node) {
+ return ``;
+}
+
+/**
+ * @param {GroupNode} node
+ * @returns {string}
+ */
+function renderGroup(node) {
+ const parts = [];
+
+ parts.push(renderImage(node.anchor));
+
+ // First child's blanks are the original spacing between anchor and the
+ // next image. Emit them so multiple blank lines are preserved.
+ // Then guarantee at least one blank line exists before .
+ const firstBlanks = node.children[0].blanks;
+ if (firstBlanks.length === 0) parts.push("");
+ for (const b of firstBlanks) parts.push(b.raw);
+
+ parts.push("");
+ parts.push(`${node.anchor.alt}
`);
+ parts.push(""); // 1 blank line after
+
+ // first child: blanks already emitted above
+ parts.push(renderImage(node.children[0].image));
+ for (let ci = 1; ci < node.children.length; ci++) {
+ const { blanks, image } = node.children[ci];
+ for (const b of blanks) parts.push(b.raw);
+ parts.push(renderImage(image));
+ }
+
+ parts.push("
");
+ parts.push("
");
+ return parts.join("\n");
+}
+
+/**
+ * @param {OutputNode[]} nodes
+ * @returns {string}
+ */
+function render(nodes) {
+ return nodes
+ .map((n) => (n.type === "group" ? renderGroup(n) : n.raw))
+ .join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Read a markdown file, group similar consecutive images into
+ * blocks, and write the result.
+ *
+ * @param {string} inputPath - path to the source .md file
+ * @param {string} outputPath - path to write (may be the same as inputPath)
+ * @param {number} [threshold=0.005] - MSE cutoff; lower = stricter grouping
+ * @returns {Promise} - comparison stats (useful for callers)
+ */
+export async function renderFile(inputPath, outputPath, threshold = 0.005) {
+ const resolvedInput = resolve(inputPath);
+ const resolvedOutput = resolve(outputPath);
+ const baseDir = dirname(resolvedInput);
+
+ console.log(`Reading : ${resolvedInput}`);
+ console.log(`Threshold : ${threshold}`);
+
+ const raw = readFileSync(resolvedInput, "utf8");
+ const lines = raw.split("\n").map(parseLine);
+
+ /** @type {Stats} */
+ const stats = { comparisons: 0, grouped: 0 };
+ const grouped = await groupImages(lines, baseDir, threshold, stats);
+ const output = render(grouped);
+
+ writeFileSync(resolvedOutput, output, "utf8");
+
+ console.log(
+ `Comparisons: ${stats.comparisons} | grouped: ${stats.grouped}`,
+ );
+ console.log(`Wrote : ${resolvedOutput}`);
+
+ return stats;
+}
+
+// ---------------------------------------------------------------------------
+// CLI entry-point (runs only when executed directly)
+// ---------------------------------------------------------------------------
+
+const __filename = fileURLToPath(import.meta.url);
+const isMain =
+ process.argv[1] && resolve(process.argv[1]) === resolve(__filename);
+
+if (isMain) {
+ const args = process.argv.slice(2);
+ const flagIdx = args.indexOf("--threshold");
+ const threshold = flagIdx !== -1 ? parseFloat(args[flagIdx + 1]) : 0.005;
+ const positional =
+ flagIdx === -1
+ ? args
+ : args.filter(
+ (/** @type {string} */ _a, /** @type {number} */ i) =>
+ i !== flagIdx && i !== flagIdx + 1,
+ );
+
+ const inputPath = positional[0];
+ const outputPath = positional[1] || positional[0];
+
+ if (!inputPath) {
+ console.error(
+ "Usage: node group_similar.js [output.md] [--threshold 0.005]",
+ );
+ process.exit(1);
+ }
+
+ await renderFile(inputPath, outputPath, threshold);
+}
diff --git a/package.json b/package.json
index fc0f7fef..966b4842 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"license": "MIT",
"dependencies": {
"gray-matter": "^4.0.3",
+ "sharp": "^0.34.5",
"vite": "^7.3.1",
"vitepress": "2.0.0-alpha.15",
"vue": "^3.5.27",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1fded94..bac21d6c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
gray-matter:
specifier: ^4.0.3
version: 4.0.3
+ sharp:
+ specifier: ^0.34.5
+ version: 0.34.5
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@20.19.30)
@@ -186,6 +189,9 @@ packages:
search-insights:
optional: true
+ '@emnapi/runtime@1.8.1':
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
+
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
@@ -348,6 +354,143 @@ packages:
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+ '@img/colour@1.0.0':
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -714,6 +857,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -914,6 +1061,15 @@ packages:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
@@ -1296,6 +1452,11 @@ snapshots:
transitivePeerDependencies:
- '@algolia/client-search'
+ '@emnapi/runtime@1.8.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@esbuild/aix-ppc64@0.27.2':
optional: true
@@ -1380,6 +1541,102 @@ snapshots:
'@iconify/types@2.0.0': {}
+ '@img/colour@1.0.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.8.1
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
'@jridgewell/sourcemap-codec@1.5.5': {}
'@opentelemetry/api@1.9.0': {}
@@ -1714,6 +1971,8 @@ snapshots:
dequal@2.0.3: {}
+ detect-libc@2.1.2: {}
+
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -1947,6 +2206,39 @@ snapshots:
extend-shallow: 2.0.1
kind-of: 6.0.3
+ semver@7.7.3: {}
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.0.0
+ detect-libc: 2.1.2
+ semver: 7.7.3
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
shell-quote@1.8.3: {}
shiki@3.21.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 00000000..e6654d63
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+onlyBuiltDependencies:
+ - sharp
diff --git a/server.js b/server.js
index f6bb2560..15779d15 100644
--- a/server.js
+++ b/server.js
@@ -1,92 +1,118 @@
// @ts-check
-import { WebSocketServer } from 'ws';
+import { WebSocketServer } from "ws";
import { readFileSync, mkdirSync, writeFileSync } from "fs";
-import { join as joinPath } from 'path'
-import matter from 'gray-matter';
+import { join as joinPath } from "path";
+import matter from "gray-matter";
-const PORT = 8080
+import { renderFile } from "./group-similar.js";
+
+const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });
console.log(`WebSocket server running on ws://localhost:${PORT}`);
-wss.on('connection', (ws) => {
- console.log('New client connected');
+wss.on("connection", (ws) => {
+ console.log("New client connected");
- let filename = ''
- const ROOT_DIR = 'docs';
+ let filename = "";
+ const ROOT_DIR = "docs";
- ws.on('message', (message, isBinary) => {
+ ws.on("message", (message, isBinary) => {
if (message instanceof Buffer && isBinary) {
writeFileSync(joinPath(ROOT_DIR, filename), message);
return;
}
- const { name, ...payload } = JSON.parse(message.toString('utf-8'));
+ const { name, ...payload } = JSON.parse(message.toString("utf-8"));
switch (name) {
- case 'download_q': {
- const { q, duration } = payload;
- console.log(`Start ${q}`)
+ case "download_q":
+ {
+ const { q, duration } = payload;
+ console.log(`Start ${q}`);
- const q_stem = q.replace(/\.[a-z0-9]+$/, "").replace(/\[.+?\]/g, '').replace(/\s+/g, '_').trim();
+ const q_stem = q
+ .replace(/\.[a-z0-9]+$/, "")
+ .replace(/\[.+?\]/g, "")
+ .replace(/\s+/g, "_")
+ .trim();
- try {
- mkdirSync(joinPath(ROOT_DIR, q_stem));
- } catch (e) {}
+ try {
+ mkdirSync(joinPath(ROOT_DIR, q_stem));
+ } catch (e) {}
- let isChanged = false;
+ let isChanged = false;
+ const ts = [];
- const ts = [];
+ const mdFile = joinPath(ROOT_DIR, `${q_stem}.md`);
+ let { data, content } = matter(readFileSync(mdFile, "utf-8"));
+ data.video = "/" + q;
- let { data, content } = matter(readFileSync(joinPath(ROOT_DIR, `${q_stem}.md`), 'utf-8'));
- data.video = '/' + q;
+ const { dedup, interval } = data;
+ if (typeof interval === "number") {
+ for (let t = 0; t < duration; t += interval) {
+ const min = Math.floor(t / 60);
+ const sec = Math.floor(t - min * 60);
- const interval = data.interval;
- if (typeof interval === 'number') {
- for (let t = 0; t < duration; t += interval) {
- const min = Math.floor(t / 60)
- const sec = Math.floor(t - min * 60)
+ content += `\n${min}:${sec.toString().padStart(2, "0")}\n`;
+ }
+ }
- content += `\n${min}:${sec.toString().padStart(2, '0')}\n`;
+ const re = /^(?\d{1,2}):(?\d{2})\s*$/;
+ const lines = content.split("\n");
+ content = "";
+ for (const line of lines) {
+ const m = re.exec(line);
+ if (m?.groups) {
+ const { min, sec } = m.groups;
+ const t = `${min}_${sec}`;
+ ts.push(t);
+ content += `\n\n\n`;
+ isChanged = true;
+ } else {
+ content += `${line}\n`;
+ }
+ }
+
+ if (isChanged) {
+ writeFileSync(mdFile, matter.stringify(content, data));
+
+ if (dedup) {
+ renderFile(
+ mdFile,
+ mdFile,
+ typeof dedup === "number" ? dedup : undefined,
+ ).then((stats) => {
+ // Don't log a thing yet.
+ });
+ }
+
+ ws.send(JSON.stringify({ name: "seek_q_ts", q, ts }));
}
}
-
- const re = /^(?\d{1,2}):(?\d{2})\s*$/;
- const lines = content.split('\n')
- content = ''
- for (const line of lines) {
- const m = re.exec(line);
- if (m?.groups) {
- const { min, sec } = m.groups;
- const t = `${min}_${sec}`;
- ts.push(t);
- content += `\n\n\n`;
- isChanged = true;
- } else {
- content += `${line}\n`;
- }
+ break;
+ case "timed_q":
+ {
+ const { q, t } = payload;
+ const q_stem = q
+ .replace(/\.[a-z0-9]+$/, "")
+ .replace(/\[.+?\]/g, "")
+ .trim();
+ filename = t ? joinPath(q_stem, `${t}.png`) : "";
}
-
- if (isChanged){
- writeFileSync(joinPath(ROOT_DIR, `${q_stem}.md`), matter.stringify(content, data));
- ws.send(JSON.stringify({ name: "seek_q_ts", q, ts }));
+ break;
+ case "done":
+ {
+ const { q } = payload;
+ console.log(`Done ${q}`);
}
- } break;
- case 'timed_q' : {
- const { q, t } = payload;
- const q_stem = q.replace(/\.[a-z0-9]+$/, "").replace(/\[.+?\]/g, '').trim();
- filename = t ? joinPath(q_stem, `${t}.png`) : '';
- } break;
- case 'done' : {
- const { q } = payload
- console.log(`Done ${q}`)
- } break;
+ break;
}
});
- ws.on('close', () => {
- console.log('Client disconnected');
+ ws.on("close", () => {
+ console.log("Client disconnected");
});
});