splatone 0.0.5 → 0.0.6

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
@@ -14,10 +14,9 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
14
14
  ## 既知のバグ
15
15
 
16
16
  - <s>JSON.stringify(json)で変換できる大きさに制限があり、数十万件等の大きな結果を生み出すクエリは、クロール後、結果のブラウザへの転送で失敗します。</s>
17
- - サイズの問題は一部解決しました。ただし、**データの保存(ダウンロード)はまだできません。**
18
- - 問題はサーバからブラウザへ結果を渡す時にJSON.stringify(obj)で結果データをJSON文字列にする時にヒープを食いつぶしています。 とりあえず可視化だけ出来るように、データが大きいと自動で分割して送信するようにしています。ただし、送受信に時間がかかります。(左下のプログレスバーに進捗が表示されます)
19
- - **--chopped**オプションをつけると強制的に分割送信します。(ただ遅くなるだけなので意味ないです)
20
- - データ保存(ダウンロード)する際にブラウザ上でJSON.stringify(json)を呼ぶ必要があり、そこで詰まります。これの解決策は別途実装します。
17
+ - サイズの問題は、一度ファイルに書き出す事で解決しました。(v0.0.6)
18
+ - **--no-filed**オプションを付ける事で、従来の方法で実行できます。(ファイルに書き出すより多少速い)
19
+ - 以下の画像のように大きな範囲で多くのジオタグが収集できるようになりました。
21
20
 
22
21
  ![](/assets/screenshot_massive_points_bulky.png)
23
22
 
@@ -31,14 +30,15 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
31
30
  ```shell
32
31
  $ npx -y -- splatone@latest crawler --help
33
32
  使い方: crawler.js [options]
34
-
35
33
  Basic Options
36
34
  -p, --plugin 実行するプラグイン[文字列] [必須] [選択してください: "flickr"]
37
35
  -o, --options プラグインオプション [文字列] [デフォルト: "{}"]
38
36
  -k, --keywords 検索キーワード(|区切り) [文字列] [デフォルト:
39
37
  "nature,tree,flower|building,house|water,sea,river,pond"]
40
- -c, --chopped 大きなデータを細分化して送信する
38
+ -f, --filed 大きなデータをファイルとして送受信する
41
39
  [真偽] [デフォルト: true]
40
+ -c, --chopped 大きなデータを細分化して送受信する
41
+ [非推奨] [真偽] [デフォルト: false]
42
42
 
43
43
  Debug
44
44
  --debug-verbose デバッグ情報出力 [真偽] [デフォルト: false]
package/crawler.js CHANGED
@@ -28,7 +28,7 @@ import { hideBin } from 'yargs/helpers';
28
28
  // -------------------------------
29
29
  import { loadPlugins } from './lib/pluginLoader.js';
30
30
  import paletteGenerator from './lib/paletteGenerator.js';
31
- import { dfsObject } from './lib/splatone.js';
31
+ import { dfsObject, saveGeoJsonObjectAsStream } from './lib/splatone.js';
32
32
 
33
33
  const __filename = fileURLToPath(import.meta.url);
34
34
  const __dirname = dirname(__filename);
@@ -133,12 +133,19 @@ try {
133
133
  type: 'string',
134
134
  default: 'nature,tree,flower|building,house|water,sea,river,pond',
135
135
  description: '検索キーワード(|区切り)'
136
+ }).option('filed', {
137
+ group: 'Basic Options',
138
+ alias: 'f',
139
+ type: 'boolean',
140
+ default: true,
141
+ description: '大きなデータをファイルとして送受信する'
136
142
  }).option('chopped', {
137
143
  group: 'Basic Options',
138
144
  alias: 'c',
139
145
  type: 'boolean',
140
146
  default: false,
141
- description: '大きなデータを細分化して送信する'
147
+ deprecate: true,
148
+ description: '大きなデータを細分化して送受信する'
142
149
  // }).option('debug-save', {
143
150
  // group: 'Debug',
144
151
  // type: 'boolean',
@@ -169,6 +176,10 @@ try {
169
176
  if (Object.keys(all_visualizers).filter(v => argv["vis-" + v]).length == 0) {
170
177
  throw new Error('可視化ツールの指定がありません。最低一つは指定してください。');
171
178
  }
179
+ if (argv.filed && argv.chopped) {
180
+ console.warn("--filedと--choppedが両方指定されています。--filedが優先されます。");
181
+ argv.chopped = false;
182
+ }
172
183
  return true;
173
184
  });
174
185
  const argv = await yargv.parseAsync();
@@ -601,10 +612,10 @@ try {
601
612
 
602
613
  console.log('[splatone:finish]');
603
614
  try {
604
- if (argv.chopped) {
615
+ if (argv.chopped || argv.filed) {
605
616
  throw new RangeError("Invalid string length");
606
617
  }
607
- await io.to(p.sessionId).timeout(5000).emitWithAck('result', {
618
+ await io.to(p.sessionId).timeout(120000).emitWithAck('result', {
608
619
  resultId,
609
620
  geoJson,
610
621
  palette: target["splatonePalette"],
@@ -613,7 +624,7 @@ try {
613
624
  });
614
625
  } catch (e) {
615
626
  if (e instanceof RangeError && /Invalid string length/.test(String(e.message))) {
616
- const msg = (argv.chopped ? "ユーザの指定により" : "結果サイズが巨大なので") + "断片化モードでクライアントに送ります";
627
+ const msg = ((argv.chopped || argv.filed) ? "ユーザの指定により" : "結果サイズが巨大なので") + (argv.chopped ? "断片化送信" : "保存ファイル送信") + "モードでクライアントに送ります";
617
628
  if (argv.debugVerbose) {
618
629
  console.warn("[WARN] " + msg);
619
630
  }
@@ -621,50 +632,64 @@ try {
621
632
  text: msg,
622
633
  class: "warning"
623
634
  });
624
- //サイズ集計
625
- const total_features = ((s = 0, st = [geoJson], v, seen = new WeakSet) => { for (; st.length;)if ((v = st.pop()) && typeof v === 'object' && !seen.has(v)) { seen.add(v); if (Array.isArray(v.features)) s += v.features.length; for (const k in v) { const x = v[k]; if (x && typeof x === 'object') st.push(x) } } return s })();
626
- let current_features = 0
627
- await dfsObject(geoJson, async ({ path, value, kind, type }) => {
628
- if (path.length !== 0) {
629
- if (kind === "primitive" || kind === "null") {
630
- //console.log(path.join("."), "=>", `(${kind}:${type})`, value);
631
- const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
632
- resultId,
633
- path,
634
- kind,
635
- type,
636
- value
637
- });
638
- //console.log("\tACK", ackrtn);
639
- } else if (kind === "object") {
640
- //console.log(path.join("."), "=>", `(${kind}:${type})`);
641
- if (path.at(-2) == "features" && Number.isInteger(path.at(-1))) {
642
- current_features++;
635
+ if (argv.chopped) {
636
+ //サイズ集計
637
+ const total_features = ((s = 0, st = [geoJson], v, seen = new WeakSet) => { for (; st.length;)if ((v = st.pop()) && typeof v === 'object' && !seen.has(v)) { seen.add(v); if (Array.isArray(v.features)) s += v.features.length; for (const k in v) { const x = v[k]; if (x && typeof x === 'object') st.push(x) } } return s })();
638
+ let current_features = 0
639
+ await dfsObject(geoJson, async ({ path, value, kind, type }) => {
640
+ if (path.length !== 0) {
641
+ if (kind === "primitive" || kind === "null") {
642
+ //console.log(path.join("."), "=>", `(${kind}:${type})`, value);
643
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
644
+ resultId,
645
+ path,
646
+ kind,
647
+ type,
648
+ value
649
+ });
650
+ //console.log("\tACK", ackrtn);
651
+ } else if (kind === "object") {
652
+ //console.log(path.join("."), "=>", `(${kind}:${type})`);
653
+ if (path.at(-2) == "features" && Number.isInteger(path.at(-1))) {
654
+ current_features++;
655
+ }
656
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
657
+ resultId,
658
+ path,
659
+ kind,
660
+ type,
661
+ progress: { current: current_features, total: total_features }
662
+ });
663
+ //console.log("\tACK", ackrtn);
664
+ } else if (kind === "array") {
665
+ //console.log(path.join("."), "=>", `(${kind}:${type})`);
666
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-chunk', {
667
+ resultId,
668
+ path,
669
+ kind,
670
+ type
671
+ });
672
+ //console.log("\tACK", ackrtn);
643
673
  }
644
- const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
645
- resultId,
646
- path,
647
- kind,
648
- type,
649
- progress: { current: current_features, total: total_features }
650
- });
651
- //console.log("\tACK", ackrtn);
652
- } else if (kind === "array") {
653
- //console.log(path.join("."), "=>", `(${kind}:${type})`);
654
- const ackrtn = await io.to(p.sessionId).timeout(5000).emitWithAck('result-chunk', {
655
- resultId,
656
- path,
657
- kind,
658
- type
659
- });
660
- //console.log("\tACK", ackrtn);
674
+ } else {
675
+ //console.log("SKIP---------------------");
661
676
  }
662
- } else {
663
- //console.log("SKIP---------------------");
677
+ });
678
+ //console.log("finish chunks");
679
+ } else {
680
+ //保存ファイル送信(--filed)
681
+ try {
682
+ const outPath = await saveGeoJsonObjectAsStream(geoJson, 'result.' + resultId + '.json');
683
+ console.log('saved:', outPath);
684
+ const ackrtn = await io.to(p.sessionId).timeout(120000).emitWithAck('result-file', {
685
+ resultId,
686
+ });
687
+ } catch (err) {
688
+ console.error('failed:', err);
689
+ process.exitCode = 1;
664
690
  }
665
- });
666
- //console.log("finish chunks");
667
- io.to(p.sessionId).timeout(10000).emitWithAck('result', {
691
+ }
692
+ await io.to(p.sessionId).timeout(120000).emitWithAck('result', {
668
693
  resultId,
669
694
  geoJson: null, /*geoJsonは送らない*/
670
695
  palette: target["splatonePalette"],
package/lib/splatone.js CHANGED
@@ -1,6 +1,12 @@
1
1
  // 各種関数のまとめ場
2
2
  // @turf/turf v6/v7 どちらでもOK(rhumbDistanceはv7で統合済み)
3
3
  import { point, distance, rhumbDistance, bbox as turfBbox } from '@turf/turf';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { pipeline as pipelineCb } from 'node:stream';
8
+ import { promisify } from 'node:util';
9
+ import { JsonStreamStringify } from 'json-stream-stringify';
4
10
 
5
11
  /**
6
12
  * bbox の幅・高さを指定単位で返す
@@ -17,8 +23,8 @@ export function bboxSize(bbox, units = "kilometers", method = "geodesic") {
17
23
  const midLon = (minX + maxX) / 2;
18
24
 
19
25
  // 測りたい2点を作成
20
- const west = point([minX, midLat]);
21
- const east = point([maxX, midLat]);
26
+ const west = point([minX, midLat]);
27
+ const east = point([maxX, midLat]);
22
28
  const south = point([midLon, minY]);
23
29
  const north = point([midLon, maxY]);
24
30
 
@@ -26,7 +32,7 @@ export function bboxSize(bbox, units = "kilometers", method = "geodesic") {
26
32
  const distFn = method === "rhumb" ? rhumbDistance : distance;
27
33
 
28
34
  return {
29
- width: distFn(west, east, { units }),
35
+ width: distFn(west, east, { units }),
30
36
  height: distFn(south, north, { units }),
31
37
  units
32
38
  };
@@ -127,9 +133,19 @@ function describeType(value) {
127
133
  const ctor = value?.constructor?.name ?? "Object";
128
134
  return { kind: "object", type: ctor, isLeaf: false, isNull: false };
129
135
  }
130
-
131
-
136
+ const pipeline = promisify(pipelineCb);
137
+ export async function saveGeoJsonObjectAsStream(geoJsonObject, outfile) {
138
+ const dir = path.join('public', 'out');
139
+ await mkdir(dir, { recursive: true });
140
+ const destPath = path.join(dir, path.basename(outfile));
141
+
142
+ // ここでも JSON.stringify は呼ばず、ストリームで直に文字列化
143
+ const src = new JsonStreamStringify(geoJsonObject);
144
+ const dest = createWriteStream(destPath, { flags: 'w' });
145
+ await pipeline(src, dest);
146
+ return destPath;
147
+ }
132
148
  export default {
133
- dfsObject,bboxSize
149
+ dfsObject, bboxSize, saveGeoJsonObjectAsStream
134
150
  };
135
151
  //const {width,height} = bboxSize(b, "kilometers", "geodesic");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splatone",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Multi-layer Composite Heatmap",
5
5
  "homepage": "https://github.com/YokoyamaLab/Splatone#readme",
6
6
  "bugs": {
@@ -27,6 +27,7 @@
27
27
  "express": "^5.1.0",
28
28
  "flickr-sdk": "^7.1.0",
29
29
  "iwanthue": "^2.0.0",
30
+ "json-stream-stringify": "^3.1.6",
30
31
  "open": "^10.2.0",
31
32
  "piscina": "^5.1.3",
32
33
  "socket.io": "^4.8.1",
@@ -61,4 +61,77 @@ function loadAsset(url) {
61
61
  }
62
62
 
63
63
  return Promise.reject(new Error(`Unsupported extension: ${url}`));
64
- }
64
+ }
65
+
66
+ async function fetchJsonObject(url, { signal } = {}) {
67
+ const res = await fetch(url, {
68
+ method: 'GET',
69
+ headers: { 'Accept': 'application/json' },
70
+ // same-originのときは不要。CORS越えならサーバ側のCORS許可が必要
71
+ signal,
72
+ cache: 'no-cache', // 必要なら更新を強制
73
+ });
74
+
75
+ if (!res.ok) {
76
+ // ステータスコード付きで例外
77
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
78
+ }
79
+
80
+ // 自動で JSON parse され、JSオブジェクトになる
81
+ return await res.json();
82
+ }
83
+
84
+ /**
85
+ * 指定のサーバ上パス (filePath) から JSON を取得し、
86
+ * ブラウザで downloadFileName という名前でダウンロードさせる。
87
+ *
88
+ * @param {string} downloadFileName - 例: "data.geojson" / "result.json"
89
+ * @param {string} filePath - 例: "/public/out/data.geojson" or "https://example.com/data.json"
90
+ * @param {object} [opts]
91
+ * @param {number} [opts.timeoutMs=15000] - タイムアウト(ms)
92
+ * @param {RequestInit} [opts.fetchInit] - fetch の追加オプション(ヘッダ/credentials等)
93
+ */
94
+ async function downloadJSONFile(downloadFileName, filePath, opts = {}) {
95
+ const { timeoutMs = 15000, fetchInit = {} } = opts;
96
+
97
+ // タイムアウト制御
98
+ const ac = new AbortController();
99
+ const timer = setTimeout(() => ac.abort(new Error('timeout')), timeoutMs);
100
+
101
+ try {
102
+ const res = await fetch(filePath, {
103
+ method: 'GET',
104
+ headers: { 'Accept': 'application/json', ...(fetchInit.headers || {}) },
105
+ signal: ac.signal,
106
+ // CORS が必要なら credentials / mode などを fetchInit で渡す(例: { credentials: 'include' })
107
+ ...fetchInit,
108
+ });
109
+
110
+ if (!res.ok) {
111
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${filePath}`);
112
+ }
113
+
114
+ // そのまま Blob 化(JSON で返ってくる想定)
115
+ // MIME が不正のときもこちらで application/json に寄せる
116
+ const blob = await res.blob();
117
+ const type = res.headers.get('content-type');
118
+ const jsonBlob = type && type.includes('application/json')
119
+ ? blob
120
+ : new Blob([await blob.arrayBuffer()], { type: 'application/json;charset=utf-8' });
121
+
122
+ const url = URL.createObjectURL(jsonBlob);
123
+ try {
124
+ const a = document.createElement('a');
125
+ a.href = url;
126
+ a.download = downloadFileName; // ダウンロード名を強制
127
+ document.body.appendChild(a);
128
+ a.click();
129
+ a.remove();
130
+ } finally {
131
+ // 生成したオブジェクトURLは必ず破棄
132
+ URL.revokeObjectURL(url);
133
+ }
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
package/views/index.ejs CHANGED
@@ -151,6 +151,7 @@
151
151
  let sessionId = null;
152
152
  let visualizers = {};
153
153
  let results = {};
154
+ let files = {};
154
155
  let layerControl = null;
155
156
  socket.on("welcome", async (res) => {
156
157
  //console.log(`welcome ${res.sessionId} at ${new Date(res.time).toLocaleTimeString()}`);
@@ -251,19 +252,37 @@
251
252
  }
252
253
  socket.on("toast", (arg) => {
253
254
  const option = {
254
- text:"",
255
- duration:arg.close?-1:5000,
256
- close:false,
257
- gravity:'bottom',
258
- position:'center',
259
- className:"toast-" + arg.class,
260
- ...arg};
255
+ text: "",
256
+ duration: arg.close ? -1 : 5000,
257
+ close: false,
258
+ gravity: 'bottom',
259
+ position: 'center',
260
+ className: "toast-" + arg.class,
261
+ ...arg
262
+ };
261
263
  Toastify(option).showToast();
262
264
  //https://apvarun.github.io/toastify-js/#
263
265
  });
266
+ socket.on("result-file", async(arg, callback) => {
267
+ try {
268
+ const file_path = '/out/result.' + arg.resultId + '.json';
269
+ latestFile = file_path;
270
+ files[arg.resultId] = file_path;
271
+ console.log("file path",file_path);
272
+ results[arg.resultId] = await fetchJsonObject(file_path);
273
+ console.log('結果ファイル取得完了');
274
+ } catch (e) {
275
+ console.error('結果ファイル取得失敗:', e);
276
+ }
277
+ //console.log("ARK");
278
+ callback({
279
+ pong: Date.now(),
280
+ ok: true
281
+ }); // only one argument is expected
282
+ });
264
283
  socket.on("result-chunk", (arg, callback) => {
265
- if(arg.progress){
266
- setProgress(document.getElementById("progressCrawl"),Math.round((arg.progress.current/arg.progress.total) * 100000) / 1000);
284
+ if (arg.progress) {
285
+ setProgress(document.getElementById("progressCrawl"), Math.round((arg.progress.current / arg.progress.total) * 100000) / 1000);
267
286
  }
268
287
  results[arg.resultId] ??= {};
269
288
  if (arg.kind === "primitive" || arg.kind === "null") {
@@ -293,19 +312,23 @@
293
312
  setAt(results[arg.resultId], arg.path, []);
294
313
  }
295
314
  //console.log("ARK");
296
- callback({ pong: Date.now(), ok: true }); // only one argument is expected
315
+ callback({
316
+ pong: Date.now(),
317
+ ok: true
318
+ }); // only one argument is expected
297
319
  });
298
320
 
299
321
  let latestResult = {};
322
+ let latestFile = null;
300
323
  const overlays = {};
301
- socket.on('result', async (res,callback) => {
324
+ socket.on('result', async (res, callback) => {
302
325
  //console.log("result");
303
326
  latestResult["_"]["visualizers"] = res.visualizers;
304
327
  latestResult["_"]["plugin"] = res.plugin;
305
328
  clearHexHighlight();
306
- if(res.geoJson == null){
329
+ if (res.geoJson == null) {
307
330
  res.geoJson = results[res.resultId];
308
- }else{
331
+ } else {
309
332
  results[res.resultId] = res.geoJson;
310
333
  }
311
334
  latestResult = {
@@ -324,7 +347,7 @@
324
347
  overlays[`[${vis}]`] = layers;
325
348
  } else {
326
349
  for (const name in layers) {
327
- console.log("Lay",vis,name);
350
+ console.log("Lay", vis, name);
328
351
  overlays[`[${vis}] ${name}`] = layers[name];
329
352
  }
330
353
  }
@@ -354,12 +377,19 @@
354
377
  });
355
378
  layerControl.addTo(map);
356
379
  document.getElementById("map_legend")?.appendChild(ul);
357
- document.getElementById("download-json").addEventListener("click", () => {
380
+ document.getElementById("download-json").addEventListener("click", async () => {
358
381
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
359
- downloadJSON(`data-${stamp}.json`, latestResult);
382
+ if(latestFile===null){
383
+ downloadJSON(`splatone-${stamp}.json`, latestResult);
384
+ }else{
385
+ await downloadJSONFile(`splatone-${stamp}.json`, latestFile);
386
+ }
360
387
  let sessionId = null;
361
388
  });
362
- callback({ pong: Date.now(), ok: true });
389
+ callback({
390
+ pong: Date.now(),
391
+ ok: true
392
+ });
363
393
  });
364
394
 
365
395
  // 地図