@geekjourneyx/findo 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## v1.0.0
4
+
5
+ - Initial stable CLI contract.
6
+ - Add Bocha web search, Volcengine web-grounded answer, and Zhihu search/hotlist adapters.
7
+ - Add JSON envelope, source status, stable error codes, and release gates.
8
+ - Add GitHub Actions CI and tag-triggered cross-platform release builds.
9
+ - Add npm global installer package for the matching GitHub Release binary.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Findo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ <div align="center">
2
+
3
+ # Findo
4
+
5
+ **Search Chinese web sources from one Go CLI.**
6
+
7
+ <img src="assets/banner.webp" alt="Findo - Chinese web sources from one Go CLI" width="100%">
8
+
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
10
+ [![Go](https://img.shields.io/badge/Go-1.23+-00ADD8.svg)](./go.mod)
11
+
12
+ </div>
13
+
14
+ Findo queries Bocha, Volcengine Ark, and Zhihu through provider APIs, then returns normalized terminal output or automation-safe JSON. It is built for developers, AI agents, and research workflows that need Chinese internet retrieval without scraping, browser sessions, or hidden side effects.
15
+
16
+ ## Install
17
+
18
+ Recommended:
19
+
20
+ ```bash
21
+ npm install -g @geekjourneyx/findo
22
+ findo version
23
+ ```
24
+
25
+ The npm package installs the matching GitHub Release binary for your platform and verifies it against `SHA256SUMS`.
26
+
27
+ Alternative Go install:
28
+
29
+ ```bash
30
+ go install github.com/geekjourneyx/findo/cmd/findo@v1.0.0
31
+ ```
32
+
33
+ Prebuilt binaries and checksums are available on the [GitHub Releases](https://github.com/geekjourneyx/findo/releases) page.
34
+
35
+ From a local checkout:
36
+
37
+ ```bash
38
+ make build
39
+ ./findo version
40
+ ```
41
+
42
+ ## Configure
43
+
44
+ Set the credentials for the providers you want to use:
45
+
46
+ | Provider | Environment variables |
47
+ | --- | --- |
48
+ | Bocha | `BOCHA_API_KEY` |
49
+ | Volcengine Ark | `ARK_API_KEY` or `VOLCENGINE_API_KEY` |
50
+ | Zhihu | `ZHIHU_ACCESS_SECRET` or `ZHIHU_API_KEY` |
51
+
52
+ Configuration precedence is:
53
+
54
+ 1. CLI flags
55
+ 2. Environment variables
56
+ 3. Config file
57
+ 4. Built-in defaults
58
+
59
+ ## Quick Start
60
+
61
+ ```bash
62
+ findo version
63
+ findo sources --json
64
+
65
+ BOCHA_API_KEY=... findo bocha "AI Agent 商业化" --json
66
+ ARK_API_KEY=... findo volc "瑞幸咖啡 2026 门店数是否可信" --json
67
+ ZHIHU_ACCESS_SECRET=... findo zhihu "AI 搜索" --json
68
+ ZHIHU_ACCESS_SECRET=... findo zhihu web "ChatGPT 桌面版" --json
69
+ ZHIHU_ACCESS_SECRET=... findo hot zhihu --json
70
+ ```
71
+
72
+ Human output defaults to a table. Use `--json` for scripts, agents, CI, and downstream tools.
73
+
74
+ ## Output
75
+
76
+ Retrieval commands return a stable envelope. A successful JSON response looks like this:
77
+
78
+ ```json
79
+ {
80
+ "version": "1.0.0",
81
+ "query": {
82
+ "text": "AI Agent 商业化",
83
+ "mode": "search",
84
+ "sources": ["bocha_web"],
85
+ "limit": 10
86
+ },
87
+ "status": "ok",
88
+ "results": [
89
+ {
90
+ "source": "bocha_web",
91
+ "title": "Example result title",
92
+ "url": "https://example.com/article",
93
+ "snippet": "A normalized summary from the provider response."
94
+ }
95
+ ],
96
+ "source_status": [
97
+ {
98
+ "source": "bocha_web",
99
+ "status": "ok",
100
+ "results": 1,
101
+ "effective_limit": 10,
102
+ "duration_ms": 842,
103
+ "error": null
104
+ }
105
+ ],
106
+ "errors": []
107
+ }
108
+ ```
109
+
110
+ Exit codes are part of the public contract:
111
+
112
+ | Code | Meaning |
113
+ | --- | --- |
114
+ | `0` | Success |
115
+ | `1` | Partial success |
116
+ | `2` | Invalid argument |
117
+ | `3` | Config error |
118
+ | `4` | Credential missing |
119
+ | `5` | Source error |
120
+ | `6` | Timeout |
121
+ | `7` | No results |
122
+ | `9` | Internal error |
123
+
124
+ ## Sources
125
+
126
+ | Source ID | Command | Provider | Capability |
127
+ | --- | --- | --- | --- |
128
+ | `bocha_web` | `findo bocha` | Bocha | Web search |
129
+ | `volcengine_answer` | `findo volc` | Volcengine Ark | Web-grounded answer |
130
+ | `zhihu_search` | `findo zhihu` | Zhihu | In-site search |
131
+ | `zhihu_web` | `findo zhihu web` | Zhihu | Global web search |
132
+ | `zhihu_hot` | `findo hot zhihu` | Zhihu | Hotlist |
133
+
134
+ Zhihu global search supports provider-specific filters:
135
+
136
+ ```bash
137
+ findo zhihu web "ChatGPT 桌面版" \
138
+ --filter 'host=="example.com"' \
139
+ --search-db realtime \
140
+ --json
141
+ ```
142
+
143
+ `--filter` and `--search-db` are only valid for `findo zhihu web`.
144
+
145
+ ## Automation Contract
146
+
147
+ Findo keeps the automation contract narrow and predictable:
148
+
149
+ - stdout is reserved for results.
150
+ - stderr is reserved for diagnostics.
151
+ - JSON output keeps source IDs, source status, error codes, and exit codes stable.
152
+ - Provider behavior stays behind typed Go adapters.
153
+ - Built-in timeout defaults to `45s`; override it with `--timeout` when a workflow needs a different budget.
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ make build
159
+ make test
160
+ make lint
161
+ make release-check
162
+ ```
163
+
164
+ The normal test suite does not require real provider credentials. Real API smoke checks are separate:
165
+
166
+ ```bash
167
+ make smoke-bocha
168
+ make smoke-volcengine
169
+ make smoke-zhihu
170
+ ```
171
+
172
+ ## Non-Goals
173
+
174
+ Findo v1.0.0 intentionally does not implement browser scraping, cache, reranking, plugin runtime, MCP, stdin query input, Bocha image search, or Zhihu direct answer. Those boundaries keep the CLI small, testable, and stable.
175
+
176
+ ## License
177
+
178
+ [MIT](./LICENSE)
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@geekjourneyx/findo",
3
+ "version": "1.0.0",
4
+ "description": "Install the findo CLI from the matching GitHub Release bundle.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/geekjourneyx/findo.git"
9
+ },
10
+ "homepage": "https://github.com/geekjourneyx/findo#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/geekjourneyx/findo/issues"
13
+ },
14
+ "bin": {
15
+ "findo": "scripts/run.js"
16
+ },
17
+ "scripts": {
18
+ "pack:check": "npm pack --json --dry-run",
19
+ "postinstall": "node scripts/install.js"
20
+ },
21
+ "files": [
22
+ "scripts/install.js",
23
+ "scripts/run.js",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
@@ -0,0 +1,274 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const http = require("http");
4
+ const https = require("https");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { execFileSync } = require("child_process");
8
+ const { fileURLToPath } = require("url");
9
+ const zlib = require("zlib");
10
+
11
+ const pkg = require("../package.json");
12
+
13
+ const VERSION = pkg.version;
14
+ const REPO = "geekjourneyx/findo";
15
+ const PACKAGE_NAME = pkg.name;
16
+
17
+ const TARGETS = {
18
+ darwin: {
19
+ x64: { goos: "darwin", goarch: "amd64", archive: "tar.gz" },
20
+ arm64: { goos: "darwin", goarch: "arm64", archive: "tar.gz" },
21
+ },
22
+ linux: {
23
+ x64: { goos: "linux", goarch: "amd64", archive: "tar.gz" },
24
+ arm64: { goos: "linux", goarch: "arm64", archive: "tar.gz" },
25
+ },
26
+ win32: {
27
+ x64: { goos: "windows", goarch: "amd64", archive: "zip" },
28
+ arm64: { goos: "windows", goarch: "arm64", archive: "zip" },
29
+ },
30
+ };
31
+
32
+ const target = TARGETS[process.platform]?.[process.arch];
33
+ const releaseBaseUrl =
34
+ process.env.FINDO_RELEASE_BASE_URL ||
35
+ `https://github.com/${REPO}/releases/download/v${VERSION}`;
36
+ const binaryName = process.platform === "win32" ? "findo.exe" : "findo";
37
+ const binDir = path.join(__dirname, "..", "bin");
38
+ const destination = path.join(binDir, binaryName);
39
+
40
+ if (!target) {
41
+ console.error(
42
+ [
43
+ `Unsupported platform for ${PACKAGE_NAME}: ${process.platform}-${process.arch}`,
44
+ "Supported npm install targets are:",
45
+ " - darwin-x64",
46
+ " - darwin-arm64",
47
+ " - linux-x64",
48
+ " - linux-arm64",
49
+ " - win32-x64",
50
+ " - win32-arm64",
51
+ ].join("\n")
52
+ );
53
+ process.exit(1);
54
+ }
55
+
56
+ const archiveName = `findo_${VERSION}_${target.goos}_${target.goarch}.${target.archive}`;
57
+
58
+ function hasScheme(value) {
59
+ return (
60
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) && !path.win32.isAbsolute(value)
61
+ );
62
+ }
63
+
64
+ function resolveAssetLocation(base, name) {
65
+ if (!hasScheme(base)) {
66
+ return path.join(base, name);
67
+ }
68
+
69
+ return base.endsWith("/") ? `${base}${name}` : `${base}/${name}`;
70
+ }
71
+
72
+ function downloadToFile(source, destinationPath) {
73
+ if (!hasScheme(source)) {
74
+ fs.copyFileSync(source, destinationPath);
75
+ return Promise.resolve();
76
+ }
77
+
78
+ if (source.startsWith("file://")) {
79
+ fs.copyFileSync(fileURLToPath(source), destinationPath);
80
+ return Promise.resolve();
81
+ }
82
+
83
+ return new Promise((resolve, reject) => {
84
+ const client = source.startsWith("https:") ? https : http;
85
+
86
+ client
87
+ .get(source, (response) => {
88
+ if (
89
+ (response.statusCode === 301 ||
90
+ response.statusCode === 302 ||
91
+ response.statusCode === 307 ||
92
+ response.statusCode === 308) &&
93
+ response.headers.location
94
+ ) {
95
+ response.resume();
96
+ downloadToFile(response.headers.location, destinationPath).then(
97
+ resolve,
98
+ reject
99
+ );
100
+ return;
101
+ }
102
+
103
+ if (response.statusCode !== 200) {
104
+ response.resume();
105
+ reject(
106
+ new Error(
107
+ `download failed with status ${response.statusCode}: ${source}`
108
+ )
109
+ );
110
+ return;
111
+ }
112
+
113
+ const file = fs.createWriteStream(destinationPath);
114
+ response.pipe(file);
115
+ file.on("finish", () => file.close(resolve));
116
+ file.on("error", reject);
117
+ })
118
+ .on("error", reject);
119
+ });
120
+ }
121
+
122
+ function sha256(filePath) {
123
+ const hash = crypto.createHash("sha256");
124
+ hash.update(fs.readFileSync(filePath));
125
+ return hash.digest("hex");
126
+ }
127
+
128
+ function expectedChecksum(checksumsPath, filename) {
129
+ const line = fs
130
+ .readFileSync(checksumsPath, "utf8")
131
+ .split(/\r?\n/)
132
+ .find((entry) => entry.trim().endsWith(` ${filename}`));
133
+
134
+ if (!line) {
135
+ throw new Error(`SHA256SUMS does not contain an entry for ${filename}`);
136
+ }
137
+
138
+ return line.trim().split(/\s+/)[0].toLowerCase();
139
+ }
140
+
141
+ function extractArchive(archivePath, extractDir) {
142
+ if (archiveName.endsWith(".tar.gz")) {
143
+ extractTarGz(archivePath, extractDir);
144
+ return;
145
+ }
146
+
147
+ if (archiveName.endsWith(".zip") && process.platform === "win32") {
148
+ const archiveLiteral = powershellSingleQuoted(archivePath);
149
+ const extractLiteral = powershellSingleQuoted(extractDir);
150
+ execFileSync("powershell.exe", [
151
+ "-NoProfile",
152
+ "-Command",
153
+ `Expand-Archive -LiteralPath ${archiveLiteral} -DestinationPath ${extractLiteral} -Force`,
154
+ ]);
155
+ return;
156
+ }
157
+
158
+ throw new Error(`unsupported archive format for this platform: ${archiveName}`);
159
+ }
160
+
161
+ function powershellSingleQuoted(value) {
162
+ return `'${String(value).replace(/'/g, "''")}'`;
163
+ }
164
+
165
+ function readTarString(buffer, start, length) {
166
+ return buffer
167
+ .subarray(start, start + length)
168
+ .toString("utf8")
169
+ .replace(/\0.*$/, "");
170
+ }
171
+
172
+ function extractTarGz(archivePath, extractDir) {
173
+ const data = zlib.gunzipSync(fs.readFileSync(archivePath));
174
+ let offset = 0;
175
+
176
+ while (offset + 512 <= data.length) {
177
+ const header = data.subarray(offset, offset + 512);
178
+ if (header.every((byte) => byte === 0)) {
179
+ break;
180
+ }
181
+
182
+ const name = readTarString(header, 0, 100);
183
+ const prefix = readTarString(header, 345, 155);
184
+ const fullName = prefix ? `${prefix}/${name}` : name;
185
+ const sizeText = readTarString(header, 124, 12).trim();
186
+ const size = sizeText ? parseInt(sizeText, 8) : 0;
187
+ const typeflag = readTarString(header, 156, 1) || "0";
188
+ const bodyOffset = offset + 512;
189
+ const nextOffset = bodyOffset + Math.ceil(size / 512) * 512;
190
+
191
+ const destinationPath = path.resolve(extractDir, fullName);
192
+ const extractRoot = path.resolve(extractDir);
193
+ if (
194
+ destinationPath !== extractRoot &&
195
+ !destinationPath.startsWith(`${extractRoot}${path.sep}`)
196
+ ) {
197
+ throw new Error(`refusing to extract path outside target directory: ${fullName}`);
198
+ }
199
+
200
+ if (typeflag === "5") {
201
+ fs.mkdirSync(destinationPath, { recursive: true });
202
+ } else if (typeflag === "0") {
203
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
204
+ fs.writeFileSync(destinationPath, data.subarray(bodyOffset, bodyOffset + size));
205
+ }
206
+
207
+ offset = nextOffset;
208
+ }
209
+ }
210
+
211
+ function findBinary(root) {
212
+ const entries = fs.readdirSync(root, { withFileTypes: true });
213
+ for (const entry of entries) {
214
+ const fullPath = path.join(root, entry.name);
215
+ if (entry.isDirectory()) {
216
+ const found = findBinary(fullPath);
217
+ if (found) {
218
+ return found;
219
+ }
220
+ } else if (entry.isFile() && entry.name === binaryName) {
221
+ return fullPath;
222
+ }
223
+ }
224
+ return "";
225
+ }
226
+
227
+ async function install() {
228
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "findo-npm-"));
229
+ const archivePath = path.join(tmpDir, archiveName);
230
+ const checksumsPath = path.join(tmpDir, "SHA256SUMS");
231
+ const extractDir = path.join(tmpDir, "extract");
232
+
233
+ try {
234
+ fs.mkdirSync(binDir, { recursive: true });
235
+ fs.mkdirSync(extractDir, { recursive: true });
236
+
237
+ await downloadToFile(
238
+ resolveAssetLocation(releaseBaseUrl, archiveName),
239
+ archivePath
240
+ );
241
+ await downloadToFile(
242
+ resolveAssetLocation(releaseBaseUrl, "SHA256SUMS"),
243
+ checksumsPath
244
+ );
245
+
246
+ const expected = expectedChecksum(checksumsPath, archiveName);
247
+ const actual = sha256(archivePath);
248
+ if (expected !== actual) {
249
+ throw new Error(`checksum mismatch for ${archiveName}`);
250
+ }
251
+
252
+ extractArchive(archivePath, extractDir);
253
+ const extractedBinary = findBinary(extractDir);
254
+ if (!extractedBinary) {
255
+ throw new Error(`archive does not contain ${binaryName}`);
256
+ }
257
+
258
+ fs.copyFileSync(extractedBinary, destination);
259
+ if (process.platform !== "win32") {
260
+ fs.chmodSync(destination, 0o755);
261
+ }
262
+
263
+ console.log(
264
+ `Installed findo ${VERSION} from ${resolveAssetLocation(releaseBaseUrl, archiveName)}`
265
+ );
266
+ } finally {
267
+ fs.rmSync(tmpDir, { recursive: true, force: true });
268
+ }
269
+ }
270
+
271
+ install().catch((error) => {
272
+ console.error(`Failed to install ${PACKAGE_NAME}: ${error.message}`);
273
+ process.exit(1);
274
+ });
package/scripts/run.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+
7
+ const ext = process.platform === "win32" ? ".exe" : "";
8
+ const binaryPath = path.join(__dirname, "..", "bin", `findo${ext}`);
9
+
10
+ if (!fs.existsSync(binaryPath)) {
11
+ console.error(
12
+ "findo binary is missing. Reinstall with `npm install -g @geekjourneyx/findo`."
13
+ );
14
+ process.exit(1);
15
+ }
16
+
17
+ try {
18
+ execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
19
+ } catch (error) {
20
+ if (typeof error.status === "number") {
21
+ process.exit(error.status);
22
+ }
23
+
24
+ console.error(`Failed to launch findo: ${error.message}`);
25
+ process.exit(1);
26
+ }