@normis's "FEEDBACK" page had a question about search, so I used a quick experiment in AI. I believe AI much better at helping with tooling around RouterOS than writing you some complex RSC config file... so thought this was a good example:
Since I'm perhaps too familar with MikroTik documentation scheme, the answer was essentially "no" since manual.mikrotik.com has no backend – everything is static HTML. But since presumably @bcbigb has some script that's used the above results.... so while a simple curl/fetch()/requests is not going to get any data, with a bit more code the same (perhaps better) result is possible.
So I asked https://gemini.google.com (after setting "Pro 3.1" and thinking to "Extended") to see if could unwind the client-side JavaScript:
I like a small bun script that use uses the index/search from manual.mikrotik.com so I can do a search at the cli via a script using downloaded version of the Lunr-based indexes on the manual.mikrotik.com. one file please, using bun.sh. e.g. https://manual.mikrotik.com/lunr-index-1783323237471.json and https://manual.mikrotik.com/search-doc-1783323237471.json. the script likely have to fetch main page the Lunr-based files changes. the backend uses docusaurus with Lunr search plugin.
Side note on prompt... Personally, I know TypeScript/JavaScript very well, so that what I use, since the clunkers cannot fool me on TS/JS things. And, here, the search used by MikroTik is already JS. But in most cases... steering the LLM toward a single-file python script is a better call, since Python is even more "agent friendly" than JavaScript. Now only marginally, there is "shit ton" of training on both... but python is like more "human readable" unless familiar with JS. In either case, you're likely also better offer clarify in prompt to "use bun for TypeScript/JavaScript" or "use uv for Python", since the generated code can be one file including any needed dependancies and allows AI avoid dealing package.json/requirements.txt/etc.
Gemini's initial script did not work as one-shot, but very close (forgot a " when parsing src in when scripting MT's manual's HTML). So then I compare Gemini fixing it own script, as well as Claude Code with Sonnet 5 try to "fix the broken script", ask GPT 5.5 to see which is better.
Verdict: claude-sonnet.ts:1 is better as-is.
Quick tests, using isolated temp caches:
- claude-sonnet.ts --update: succeeded. Found active hash 1783323237471 and downloaded both JSON files.
- gemini.ts --update: failed with Could not dynamically locate the Docusaurus search index hashes.
- original gemini-code-1783360951308.ts --update: same discovery failure.
- With a valid cache, both fixed scripts returned good-looking results for dhcp lease and firewall nat.
- Original still crashes on current search-doc.json because the file is an object with searchDocs, not a top-
level array.
The key bug in gemini.ts: live Docusaurus HTML currently has unquoted script tags like src=/assets/js/main...js.
Claude’s regex handles that at claude-sonnet.ts:59; Gemini’s fallback only matches quoted src="..." at
gemini.ts:100.
Gemini’s version does have one better idea: its document parsing is more defensive, including fallback/minified
keys at gemini.ts:180. If you fixed its script-tag regex, I’d say it has the better search-data handling. But
cold-start update is the most important reliability path for this tool, and Gemini currently fails there.
Common flaw: neither script actually uses the downloaded Lunr index; both download lunr-index.json and then do a
custom substring scorer over search-doc.json. They also won’t auto-detect a changed upstream hash after cache
exists unless you pass --update.
Best combined version would be: Claude discovery + Gemini cache parsing + actual Lunr search or at least a hash
freshness check.
I then had GPT 5.5 create a 4th version of same script after prompting:
› can you produce you combined codex.ts with the improvements, including actual Lunr search and freshness check.
So one answer to @bcbigb original problem that curl-like search of help.mikrotik.com no longer worked on the static + client-side-JS manual.mikrotik.com site is the following longer script from a series of 4 prompts and about 30 minutes. And took longer to write the post...
CLI search using manual.mikrotik.com using bun script
#!/usr/bin/env bun
import { mkdir, rename } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
const BASE_URL = "https://manual.mikrotik.com";
const CACHE_DIR =
process.env.MIKROTIK_MANUAL_CACHE_DIR ?? join(homedir(), ".cache", "mikrotik-manual-cli");
const MANIFEST_PATH = join(CACHE_DIR, "manifest.json");
const DOC_CACHE_PATH = join(CACHE_DIR, "search-doc.json");
const INDEX_CACHE_PATH = join(CACHE_DIR, "lunr-index.json");
const DEFAULT_LIMIT = 7;
type SearchPaths = {
hash: string;
docUrl: string;
indexUrl: string;
};
type Manifest = Partial<SearchPaths> & {
updatedAt?: number;
checkedAt?: number;
docs?: number;
};
type DocItem = {
id?: string | number;
i?: string | number;
title?: string;
t?: string;
pageTitle?: string;
p?: string;
url?: string;
u?: string;
content?: string;
text?: string;
c?: string;
keywords?: string;
k?: string;
[key: string]: unknown;
};
type Options = {
forceUpdate: boolean;
offline: boolean;
json: boolean;
limit: number;
help: boolean;
query: string;
};
type LunrResult = {
ref: string;
score: number;
matchData?: unknown;
};
const colorsEnabled = !process.env.NO_COLOR && process.stdout.isTTY;
const color = {
bold: (text: string) => ansi("1", text),
dim: (text: string) => ansi("90", text),
cyan: (text: string) => ansi("36", text),
green: (text: string) => ansi("32", text),
red: (text: string) => ansi("31", text),
yellow: (text: string) => ansi("33", text),
underlineBlue: (text: string) => ansi("4;34", text),
};
function ansi(code: string, text: string): string {
return colorsEnabled ? `\x1b[${code}m${text}\x1b[0m` : text;
}
const options = parseArgs(process.argv.slice(2));
if (options.help || (!options.query && !options.forceUpdate)) {
printUsage();
process.exit(0);
}
const lunr = await loadLunr();
try {
await ensureCache(options);
if (options.query) {
await performSearch(options);
}
} catch (error) {
fail(error);
}
async function loadLunr(): Promise<any> {
try {
const mod = await import(import.meta.resolve("lunr"));
return mod.default ?? mod;
} catch (error) {
throw new Error(
"Could not import the 'lunr' package. Install it once with 'bun add lunr', or enable Bun auto-install."
);
}
}
function parseArgs(args: string[]): Options {
const parsed: Options = {
forceUpdate: false,
offline: false,
json: false,
limit: DEFAULT_LIMIT,
help: false,
query: "",
};
const queryParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
parsed.help = true;
} else if (arg === "-u" || arg === "--update") {
parsed.forceUpdate = true;
} else if (arg === "--offline") {
parsed.offline = true;
} else if (arg === "--json") {
parsed.json = true;
} else if (arg === "-n" || arg === "--limit") {
parsed.limit = parseLimit(args[++i]);
} else if (arg.startsWith("--limit=")) {
parsed.limit = parseLimit(arg.slice("--limit=".length));
} else {
queryParts.push(arg);
}
}
parsed.query = queryParts.join(" ").trim();
return parsed;
}
function parseLimit(value: string | undefined): number {
const limit = Number(value);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new Error("Limit must be an integer from 1 to 50.");
}
return limit;
}
function printUsage() {
console.log(`${color.bold("MikroTik Manual CLI Search")}`);
console.log("Usage: bun ./codex.ts [options] <search terms>");
console.log("");
console.log("Options:");
console.log(" -u, --update Force refresh the local search cache");
console.log(" --offline Search local cache without checking freshness");
console.log(" -n, --limit N Number of results to show, default 7");
console.log(" --json Print machine-readable results");
console.log(" -h, --help Show this help");
}
async function ensureCache(opts: Options) {
const hasCache = await cacheExists();
if (opts.offline && opts.forceUpdate) {
throw new Error("--offline and --update cannot be used together.");
}
if (!hasCache) {
if (opts.offline) {
throw new Error(`No local cache found in ${CACHE_DIR}. Run without --offline first.`);
}
await updateCache();
return;
}
if (opts.forceUpdate) {
await updateCache();
return;
}
if (!opts.offline) {
await refreshCacheIfChanged();
}
}
async function cacheExists(): Promise<boolean> {
return (await Bun.file(DOC_CACHE_PATH).exists()) && (await Bun.file(INDEX_CACHE_PATH).exists());
}
async function refreshCacheIfChanged() {
const manifest = await readManifest();
try {
const paths = await discoverPaths();
if (manifest.hash !== paths.hash) {
console.error(
`${color.cyan("Cache is stale:")} ${manifest.hash ?? "unknown"} -> ${paths.hash}`
);
await updateCache(paths);
return;
}
await writeManifest({
...manifest,
...paths,
checkedAt: Date.now(),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${color.yellow("Freshness check failed; using local cache:")} ${message}`);
}
}
async function readManifest(): Promise<Manifest> {
try {
const manifest = await Bun.file(MANIFEST_PATH).json();
return manifest && typeof manifest === "object" ? manifest : {};
} catch {
return {};
}
}
async function writeManifest(manifest: Manifest) {
await mkdir(CACHE_DIR, { recursive: true });
await writeJsonAtomic(MANIFEST_PATH, manifest);
}
async function discoverPaths(): Promise<SearchPaths> {
const html = await fetchText(BASE_URL);
const explicitHash = extractSearchHash(html);
if (explicitHash) {
return pathsForHash(explicitHash);
}
const candidates = new Set(extractTimestampCandidates(html));
const scriptUrls = extractScriptUrls(html);
const scriptTexts = await Promise.all(
scriptUrls.map(async (url) => {
try {
return await fetchText(url);
} catch {
return "";
}
})
);
for (const text of scriptTexts) {
const hash = extractSearchHash(text);
if (hash) {
return pathsForHash(hash);
}
for (const candidate of extractTimestampCandidates(text)) {
candidates.add(candidate);
}
}
for (const candidate of [...candidates].slice(0, 50)) {
if (await hashExists(candidate)) {
return pathsForHash(candidate);
}
}
throw new Error("Could not locate the active Docusaurus Lunr search files.");
}
function extractSearchHash(text: string): string | null {
const match = text.match(/(?:search-doc|lunr-index)-([a-zA-Z0-9]+)\.json/);
return match?.[1] ?? null;
}
function extractTimestampCandidates(text: string): string[] {
return [...new Set(text.match(/17\d{11}/g) ?? [])];
}
function extractScriptUrls(html: string): string[] {
const urls: string[] = [];
const scriptRegex = /<script\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^'"\s>]+))/gi;
let match: RegExpExecArray | null;
while ((match = scriptRegex.exec(html)) !== null) {
const src = match[1] ?? match[2] ?? match[3];
if (src) {
urls.push(toAbsoluteUrl(src));
}
}
return [...new Set(urls)];
}
function toAbsoluteUrl(value: string): string {
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("/")) {
return `${BASE_URL}${value}`;
}
return `${BASE_URL}/${value}`;
}
function pathsForHash(hash: string): SearchPaths {
return {
hash,
docUrl: `${BASE_URL}/search-doc-${hash}.json`,
indexUrl: `${BASE_URL}/lunr-index-${hash}.json`,
};
}
async function hashExists(hash: string): Promise<boolean> {
const { docUrl, indexUrl } = pathsForHash(hash);
const [doc, index] = await Promise.all([headOk(docUrl), headOk(indexUrl)]);
return doc && index;
}
async function headOk(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: "HEAD" });
return response.ok;
} catch {
return false;
}
}
async function updateCache(paths?: SearchPaths) {
const searchPaths = paths ?? (await discoverPaths());
console.error(`${color.cyan("Using search hash:")} ${color.green(searchPaths.hash)}`);
const [docData, indexData] = await Promise.all([
fetchJson(searchPaths.docUrl),
fetchJson(searchPaths.indexUrl),
]);
const docs = extractDocs(docData);
if (docs.length === 0) {
throw new Error("Downloaded search-doc file did not contain a document array.");
}
lunr.Index.load(indexData);
await mkdir(CACHE_DIR, { recursive: true });
await Promise.all([
writeJsonAtomic(DOC_CACHE_PATH, docData),
writeJsonAtomic(INDEX_CACHE_PATH, indexData),
writeJsonAtomic(MANIFEST_PATH, {
...searchPaths,
updatedAt: Date.now(),
checkedAt: Date.now(),
docs: docs.length,
} satisfies Manifest),
]);
console.error(`${color.green("Cache updated:")} ${docs.length} docs`);
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return response.text();
}
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return response.json();
}
async function writeJsonAtomic(path: string, value: unknown) {
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
await Bun.write(tempPath, `${JSON.stringify(value)}\n`);
await rename(tempPath, path);
}
async function performSearch(opts: Options) {
const [docData, indexData] = await Promise.all([
Bun.file(DOC_CACHE_PATH).json(),
Bun.file(INDEX_CACHE_PATH).json(),
]);
const docs = extractDocs(docData);
const docByRef = buildDocRefMap(docs);
const index = lunr.Index.load(indexData);
const search = searchLunr(index, opts.query);
const results = search.results
.map((result: LunrResult) => {
const doc = docByRef.get(result.ref);
if (!doc) {
return null;
}
return {
result,
doc,
displayScore: rerankScore(result.score, doc, opts.query),
};
})
.filter((value): value is { result: LunrResult; doc: DocItem; displayScore: number } =>
Boolean(value)
)
.sort((a, b) => b.displayScore - a.displayScore)
.slice(0, opts.limit);
if (opts.json) {
console.log(
JSON.stringify(
{
query: opts.query,
mode: search.mode,
count: search.results.length,
results: results.map(({ result, doc }, index) => ({
rank: index + 1,
ref: result.ref,
score: result.score,
title: cleanText(docTitle(doc)),
pageTitle: cleanText(docPageTitle(doc)),
url: docUrl(doc),
snippet: generateSnippet(docBody(doc), extractQueryTerms(opts.query), false),
})),
},
null,
2
)
);
return;
}
if (results.length === 0) {
console.log(`${color.red("No documentation entries found matching:")} "${opts.query}"`);
return;
}
console.log(`${color.bold(color.cyan("Search Results for:"))} "${opts.query}"`);
console.log(color.dim(`Mode: ${search.mode}; ${search.results.length} Lunr matches`));
console.log("");
results.forEach(({ doc }, index) => {
const title = cleanText(docTitle(doc));
const pageTitle = cleanText(docPageTitle(doc));
const section = pageTitle && pageTitle !== title ? ` ${color.dim(`(${pageTitle})`)}` : "";
const url = docUrl(doc);
const snippet = generateSnippet(docBody(doc), extractQueryTerms(opts.query), true);
console.log(`${color.bold(color.green(`[${index + 1}] ${title}`))}${section}`);
if (snippet) {
console.log(` ${snippet}`);
}
console.log(` ${color.underlineBlue(url)}`);
console.log("");
});
}
function extractDocs(rawData: unknown): DocItem[] {
if (Array.isArray(rawData)) {
return rawData as DocItem[];
}
if (!rawData || typeof rawData !== "object") {
return [];
}
const objectData = rawData as Record<string, unknown>;
if (Array.isArray(objectData.searchDocs)) {
return objectData.searchDocs as DocItem[];
}
const arrayKey = Object.keys(objectData).find((key) => {
const value = objectData[key];
return Array.isArray(value) && value.some(looksLikeDoc);
});
return arrayKey ? (objectData[arrayKey] as DocItem[]) : [];
}
function looksLikeDoc(value: unknown): boolean {
return Boolean(
value &&
typeof value === "object" &&
("title" in value || "t" in value) &&
("url" in value || "u" in value)
);
}
function buildDocRefMap(docs: DocItem[]): Map<string, DocItem> {
const map = new Map<string, DocItem>();
docs.forEach((doc, index) => {
map.set(String(index), doc);
const id = doc.id ?? doc.i;
if (id !== undefined) {
map.set(String(id), doc);
}
});
return map;
}
function searchLunr(index: any, query: string): { mode: string; results: LunrResult[] } {
try {
const direct = index.search(query) as LunrResult[];
if (direct.length > 0) {
return { mode: "lunr", results: direct };
}
} catch {
// Fall through to a tokenized Lunr query for CLI-ish paths.
}
const terms = extractQueryTerms(query);
if (terms.length === 0) {
return { mode: "empty", results: [] };
}
const tokenized = index.query((q: any) => {
for (const term of terms) {
q.term(term, { fields: ["title"], boost: 10 });
q.term(term, { fields: ["keywords"], boost: 4 });
q.term(term, { fields: ["content"], boost: 1 });
}
}) as LunrResult[];
return { mode: "lunr-tokenized", results: tokenized };
}
function extractQueryTerms(query: string): string[] {
return [
...new Set(
query
.toLowerCase()
.match(/[a-z0-9_]+/g)
?.filter(Boolean) ?? []
),
];
}
function rerankScore(score: number, doc: DocItem, query: string): number {
const compactQuery = compactForCompare(query);
if (!compactQuery) {
return score;
}
const title = compactForCompare(docTitle(doc));
const pageTitle = compactForCompare(docPageTitle(doc));
const url = compactForCompare(docUrl(doc));
let bonus = 0;
if (title === compactQuery) bonus += 500;
if (title.includes(compactQuery)) bonus += 200;
if (url.includes(compactQuery)) bonus += 150;
if (pageTitle.includes(compactQuery)) bonus += 75;
return score + bonus;
}
function compactForCompare(value: string): string {
return cleanText(value).toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function docTitle(doc: DocItem): string {
return String(doc.title ?? doc.t ?? "Untitled");
}
function docPageTitle(doc: DocItem): string {
return String(doc.pageTitle ?? doc.p ?? "");
}
function docBody(doc: DocItem): string {
return String(doc.content ?? doc.text ?? doc.c ?? "");
}
function docUrl(doc: DocItem): string {
const value = String(doc.url ?? doc.u ?? "");
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
return value.startsWith("/") ? `${BASE_URL}${value}` : `${BASE_URL}/${value}`;
}
function cleanText(value: string): string {
return value.replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/\s+/g, " ").trim();
}
function generateSnippet(text: string, terms: string[], highlight: boolean): string {
const clean = cleanText(text);
if (!clean) {
return "";
}
const lowerText = clean.toLowerCase();
let pivot = -1;
for (const term of terms) {
const index = lowerText.indexOf(term);
if (index !== -1 && (pivot === -1 || index < pivot)) {
pivot = index;
}
}
const start = pivot === -1 ? 0 : Math.max(0, pivot - 55);
const end = pivot === -1 ? Math.min(clean.length, 180) : Math.min(clean.length, pivot + 120);
let snippet = clean.slice(start, end);
if (start > 0) snippet = `...${snippet}`;
if (end < clean.length) snippet = `${snippet}...`;
if (!highlight || terms.length === 0) {
return snippet;
}
let highlighted = snippet;
for (const term of terms) {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
highlighted = highlighted.replace(new RegExp(`(${escaped})`, "gi"), color.yellow("$1"));
}
return highlighted;
}
function fail(error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
console.error(`${color.red("Error:")} ${message}`);
process.exit(1);
}
Which could then be "ported" to any other language like python or PHP or whatever by the reader using some LLM so that agent start with a much better base to solve whatever @bcbigb/anyone was using the old help.mikrotik.com doc's search for.
There is a --help for script above. **And you need https://bun.sh installed for it to work. ** Now since it she-banged, if you chmod a+x after saving it, it run like ./codex.ts (or whatever you named the above code when saved locally). Or, you invoke it via bun with path to code as codex suggests when it created the codex.ts script above:
Created codex.ts:1.
It combines the better pieces and adds:
- Real lunr.Index.load(...) search against lunr-index.json
- Freshness check on normal runs, with stale-cache fallback if offline/network fails
- --offline to skip freshness checks
- Robust Docusaurus search-file discovery, including unquoted src=/assets/...js
- Defensive search-doc parsing for searchDocs, arrays, and minified-ish keys
- Tokenized Lunr fallback for RouterOS path queries like ip/dhcp-server/lease
- --json and --limit/-n
Verified:
bun ./codex.ts --help
MIKROTIK_MANUAL_CACHE_DIR=/private/tmp/mikrotik-search-test-codex bun ./codex.ts --update firewall nat
MIKROTIK_MANUAL_CACHE_DIR=/private/tmp/mikrotik-search-test-claude/.cache/mikrotik-manual-cli bun ./codex.ts
--offline ip/dhcp-server/lease
MIKROTIK_MANUAL_CACHE_DIR=/private/tmp/mikrotik-search-test-claude/.cache/mikrotik-manual-cli bun ./codex.ts
--offline --json -n 2 firewall nat
Normal use:
bun ./codex.ts firewall nat
bun ./codex.ts --offline ip/dhcp-server/lease
bun ./codex.ts --update dhcp lease
One caveat: because it uses actual Lunr, it imports the lunr package. Your Bun cache resolves it here; if it
ever errors on another machine, bun add lunr fixes that.
Finally compared against the "real" search code using manual.mikrotik.com
While codex did figure out RouterOS paths needed to be normalized for search to work, it did not unwind the website directly... until I indirectly forced it to... so that any Lunr extensions MikroTik used were also considered.
you _(OpenAI `codex`)_ mentioned in your commentary when coding the script:
> The raw Lunr query works well for normal phrases, but path-like RouterOS queries such as ip/dhcp-server/lease return zero because Lunr query syntax/tokenization treats the punctuation differently. I’m adding a fallback that still uses Index.query(...) but tokenizes CLI paths into ip, dhcp, server, lease."
Are you saying your codex.ts script is richer/better than one built into manual.mikrotik.com? e.g. does manual.mikrotik.com also do path cannanocialization to handle the `/` things in index?
I knew this one is trickier to answer. Since manual.mikrotik.com uses client-side search, but all JavaScript used to do the search is minified. So to answer the question... I knew it had to unwind the Webpack/Docusaurus minification first. It took codex a bit more time on the intermediate step to figure out what MikroTik's JS was doing. But once it unwound minification, it answered essential "well, not quite":
No, not generally. I should phrase that more carefully.
What I tested first was plain raw Lunr:
index.search("ip/dhcp-server/lease")
That returns zero because raw Lunr query parsing does not treat RouterOS paths nicely. But the manual.mikrotik.com UI is not just raw index.search(). I inspected the current site bundle and it does add a search adapter. Current behavior includes:
lunr.tokenizer.separator = /[\s/]+/
and it tokenizes input before querying. So:
ip/dhcp-server/lease
becomes roughly:
ip
dhcp-server
lease
I emulated the current site adapter locally, and it returns ip/dhcp-server/lease as result 1. So yes: the manual search already handles / path-like queries. The difference is that codex.ts currently tokenizes more aggressively:
ip/dhcp-server/lease -> ip, dhcp, server, lease
That can be useful for CLI-style fuzzy lookup, but it is broader and may be noisier. It is not necessarily “better” than the website. The parts where codex.ts is richer are CLI-specific: offline cache, freshness check, JSON output, local index reuse, and fallback behavior when raw Lunr query parsing fails.
If you want it to match MikroTik’s UI more closely, I’d change the fallback tokenizer to preserve hyphenated tokens like dhcp-server instead of splitting them.