The collector queries IndieHackers' Algolia index and writes six columns to a local file. Explore how the native executable handles each page, rejects bad data and publishes an export.
The example product is made up. Select a stage to inspect its data. This guide never contacts Algolia.
This is a rewrite of Rinkoff's Python collector. The original four flags and six CSV columns remain. Native HTTP, explicit allocation and a checked publication sequence replace the Python runtime and its packages.
One package. Eight ownership boundaries.
The executable is a single Odin package. Select a file to see its role, what it owns and an excerpt of the current source.
config→http→json→response→exportcollector owns the loop. cancel spans it.
Start the process
main.odin
Parse the command, install signal handlers, load credentials and return an exit status.
Help exits before credentials are loaded. Invalid arguments return 2. Runtime failures return 1. A handled interrupt returns 130 or 143.
run :: proc() -> int {
arena: mem.Arena
defer mem.arena_destroy(&arena)
context.allocator = mem.arena_allocator(&arena)
context.temp_allocator = context.allocator
options, help, ok := parse_options()
if !ok {
fmt.eprintln("Use --help for usage.")
return 2
}
if help {
fmt.print(HELP)
return 0
}
if !install_interrupts() {
return 1
}
credentials, configured := load_credentials()
success := configured && collect(options, credentials)
if !success && cancelled() {
fmt.eprintln("[!] Interrupted.")
return 128 + int(interruption())
}
// Excerpt ends here.
152 lines in the file. SHA-256 bc4704fa07bcf01ba7f68ca658eb4496774a2003f6f10ed93b9d51efefa19513
Read local configuration
config.odin
Read a bounded .env file in the current directory and validate the Algolia credentials.
The environment takes precedence even when a variable is explicitly empty. The reader accepts one-line quotes and comments. It never evaluates shell expressions or searches parent directories.
Owns
The loaded credential strings.
Procedures
read_dotenv, dotenv_value, load_credentials
Read a source excerpt config.odin, lines 10 to 33
read_dotenv :: proc() -> (contents: string, ok: bool) {
file, err := os.open(".env", {.Read, .Non_Blocking})
if err == .Not_Exist {
return "", true
}
if err != nil {
fmt.eprintfln("[!] Cannot read .env: %s", os.error_string(err))
return
}
defer os.close(file)
info, stat_err := os.fstat(file, context.allocator)
if stat_err != nil || info.type != .Regular || info.size > MAX_DOTENV_BYTES {
fmt.eprintln("[!] Cannot read .env: expected a regular file no larger than 64 KiB")
return
}
buffer := make([]byte, MAX_DOTENV_BYTES+1)
total := 0
for total < len(buffer) && !cancelled() {
n, read_err := os.read(file, buffer[total:])
total += n
if read_err != nil && read_err != .EOF {
fmt.eprintfln("[!] Cannot read .env: %s", os.error_string(read_err))
return
}
// Excerpt ends here.
166 lines in the file. SHA-256 7f7a5f5ce215e0952d934a29c2f7ab2d92f4102059714815c5123644f1433084
Control the collection
collector.odin
Build filters, visit pages in order, reject duplicate IDs and decide when to publish.
Response data and formatted CSV fields die with the page arena. Nonempty IDs are copied into the collection allocator, so duplicate detection survives the next page.
Owns
Collection-wide IDs and one arena for each page.
Procedures
build_filters, collect_page, collect
Read a source excerpt collector.odin, lines 13 to 22
113 lines in the file. SHA-256 7dbcb98d913bf5c99bf8a6ebf5e8bb2f1ae2b9a55b80f64e4d476d9545cccbae
Send one search request
http.odin
Reuse a native libcurl handle, send authentication headers and collect a bounded response.
The request has a total 15-second deadline. TLS verification stays enabled. Redirects fail. No application retry runs after an error. The 16 MiB body limit applies after decompression.
Owns
The curl handle, header list, URL and transfer buffer.
186 lines in the file. SHA-256 89f98fbe40906f84fb4b0d02656e5a088f985d15cb1620564b9029ece16cf1a8
Reject malformed JSON
json.odin
Use Odin's tokenizer and string decoder with strict grammar and resource checks.
Duplicate decoded keys, trailing commas, invalid UTF-8 and unpaired surrogates fail. Integers must fit signed 64 bits. Floats must be finite. Depth and token budgets bound the parser.
Owns
A bounded reader and values allocated in the current page arena.
199 lines in the file. SHA-256 790f131fbc1b1425a683933853200b12ce208836733586b95e305ec69d848c8a
Turn hits into CSV fields
response.odin
Validate page metadata, check scalar fields and encode each product ID as one URL segment.
The response needs hits and nbPages. A supplied page must match the request. Missing fields use the original script's defaults; explicit nulls become empty strings.
Owns
A search page view and six strings for each row.
Procedures
decode_page, csv_field, decode_product
Read a source excerpt response.odin, lines 63 to 82
decode_product :: proc(value: json.Value) -> (row: Product_Row, ok: bool) {
hit, is_object := value.(json.Object)
if !is_object {
fmt.eprintln("[!] Invalid product: each hit must be an object")
return
}
keys := [?]string{"name", "tagline", "revenue", "objectID", "websiteUrl", "status"}
defaults := [?]string{"N/A", "", "0", "", "", "unknown"}
for key, i in keys {
field, valid := csv_field(hit, key, defaults[i])
if !valid {
fmt.eprintfln("[!] Invalid product: %s must be a scalar or null", key)
return
}
row.fields[i] = field
}
row.id = row.fields[3]
row.fields[3] = fmt.aprintf("https://www.indiehackers.com/product/%s", net.percent_encode(row.id))
return row, true
}
82 lines in the file. SHA-256 9413d946c62d44899d68d73b34119d37ff9ff74c57c373b58fac92b592d3b6e0
Publish the CSV
export.odin
Lock the output directory, write private staging data and check identities before rename.
A nonblocking directory lock coordinates collectors. Cleanup removes only the staging inode created by this process. The final rename is atomic; a following directory-sync error reports that publication already happened.
Owns
Directory and file descriptors, the CSV buffer and filesystem snapshots.
239 lines in the file. SHA-256 92012f3169dccecf067baa9f2ce29b6f3e488bfa3fc97a381372759a14f2728e
Handle an interrupt
cancel.odin
Record SIGINT or SIGTERM in lock-free state and interrupt transfers and page pacing.
The signal handler allocates nothing and performs no I/O. Collection observes the flag during work. The final publication block defers these signals while it finishes the commit.
interrupt_handler :: proc "c" (signal: posix.Signal) {
// Signal handlers may only touch lock-free state; no allocation or I/O.
intrinsics.atomic_store_explicit(&interrupted, i32(signal), .Relaxed)
}
49 lines in the file. SHA-256 5b6dd16107e8657cc179b07af0682c674dc633644e9ec30b9b5b36d654019edf
One page's memory
HTTP bytes, decoded JSON and formatted fields live in a page arena. The collector destroys that arena after the page is written.
↔
The collection's memory
Cloned nonempty IDs survive across pages for duplicate detection. One curl handle and one export transaction survive until collection ends.
What runs where.
The CLI and this guide have separate runtimes. Browsing this page does not run the collector.
Compile
Odin
Official monthly release dev-2026-10, tested on Linux x86-64.
Clang links the executable.
-o:speed -vet -strict-style
No package manager for the CLI.
Collect
Native process
The vendor:curl bindings call the system libcurl.
libcurl, zlib and system CA certificates
Odin JSON tokenizer and CSV encoder
Linux descriptors, locks and rename
Verify
Real executable
Node's test runner spawns the collector against loopback servers and isolated files.
Node.js 22 or later
strace and OpenSSL
AddressSanitizer for memory checks
Odin's release tags still use dev. The monthly dev-2026-10 release was the latest marked non-prerelease when checked on 17 October 2026. The archive reports dev-2026-10-nightly:84bc3fc internally.
What changed from the Python script?
Concern
Reference script
Odin rewrite
HTTP
requests
One native libcurl client with a deadline and decoded body limit
Configuration
python-dotenv
A bounded reader with explicit environment precedence
Rows
pandas DataFrame
Six fields per row through Odin's CSV encoder
Memory
A collected product list
Page arenas plus persistent duplicate IDs
Output
A direct CSV write
Private staging, identity checks, atomic rename and directory sync
Failures
Script-level error handling
Defined exit codes, cancellation and executable regression tests
Choose the query. Read the command.
Change the controls to inspect the actual filter expression. Copy the command to run it on your own machine.
At most 5 pages, with up to 100 hits per page. Algolia determines the accessible result window.
These controls only generate text. The collector reads ALGOLIA_APP_ID and ALGOLIA_API_KEY from your environment or local .env. Keep both out of commands you share.
All flags and exit codes
Flag
Default
Behavior
--revenue INTEGER
1
Inclusive minimum, signed 64-bit. Zero or negative disables this filter.
--no-code
Off
Remove _tags:founders-code-yes.
--pages INTEGER
5
Nonnegative maximum. Zero skips collection.
--output FILE
products.csv
Parent directory must already exist. Use --output=VALUE for a name starting with a dash.
--base-url URL
Algolia origin
HTTP or HTTPS origin for testing, with no path, query or userinfo. Sends the configured authentication headers to that server.
--help, -h
Print usage without credentials.
0 success or empty result /1 runtime or configuration failure /2 invalid arguments /130 SIGINT /143 SIGTERM
Value flags accept a space or an equals sign. SIGINT and SIGTERM can finish with success if publication has already entered its protected commit.
The rename is the turning point.
Step through an export with an existing CSV. Pick a failure to see which file remains. This is an explanatory model of the implementation.
Lock directoryStage rowsFlush and syncCheck identitiesRenameSync directory
Step 1 of 6
Lock the output directory.
Open the directory and acquire a nonblocking advisory lock. Snapshot the existing destination and create a private staging inode with mode 0600.
Collection is in progress.
products.csvPrevious export
Unchanged
.collector-<nonce>Private staging file
0600
The file names are illustrative. Staging uses a cryptographic 32-character hexadecimal nonce.
Before rename. Handled errors, cancellation and empty results preserve the destination. Cleanup checks the staging inode before unlinking. A foreign replacement stays untouched.
After rename. The new file is already published. If directory sync then fails, the process returns 1 and says publication happened. It does not claim to have restored the old file.
Concurrency, crashes and permissions
The directory lock permits one collector per output directory, even if collectors choose different filenames. It is advisory. Unrelated programs that ignore the lock are not serialized.
The collector checks the directory identity, destination snapshot and staging inode before rename. New destinations use a no-replace rename. New files start at mode 0600. Replacements copy the old file's ordinary permission bits. The new inode's owner and group follow the creating process and directory.
SIGINT and SIGTERM use a signal flag and normal cleanup. SIGKILL, a crash or refused cleanup can leave private staging files. A later run does not sweep those files. The kernel releases the directory lock when the process dies.
The contract, including its edges.
The CSV preserves the reference's shape. The parser and transport impose explicit limits before a row can reach publication.
Explicit nulls become empty strings. Objects and arrays fail in these fields. Numbers format individually; booleans become True or False. CSV uses UTF-8 and LF line endings.
Bounded work
HTTP deadline
15 seconds per request
Page spacing
1 second between pages
Response body
16 MiB after decompression
Search hits
At most 100 per response
JSON nesting
128 levels
JSON tokens
200,000 per response
Local .env
64 KiB, regular UTF-8 file
CSV buffer
16 KiB, checked writes
What has not been proved
A successful live IndieHackers export has not been verified. No live credentials are bundled. The products page returned a browser challenge during inspection. A real Algolia request with a deliberately invalid key returned 403, and the collector preserved its prior export.
Algolia controls the accessible result window. Paging does not promise a full index snapshot. CSV preserves raw source text, including formula-like strings that a spreadsheet may evaluate. The collector has no scheduler, database or resume checkpoint.
Seven passes. Two clean at the end.
Each verified finding led to a repair and another check. A new finding reset the clean count. The review stopped after passes six and seven found no new issues.
137Executable scenarios139 reported runner tests
108AddressSanitizer testsNo sanitizer reports
64 / 88JSON corpus bodiesValid / malformed inputs
Recorded on 17 October 2026. These checks use synthetic data. They exercise the real compiled collector, not an in-browser implementation.
1Strict input and export rules9 finding groups0/2 clean
2Process and filesystem lifecycle4 finding groups0/2 clean
Fixed destination edits during collection, handled cancellation, FIFO or oversized configuration files, and missing directory sync. One apparent short-write issue was withdrawn after the probe proved invalid.
114 scenarios after repairs. Fault injection checked the intended syscalls.
3Encoding and API origins2 finding groups0/2 clean
Rejected invalid UTF-8 in configuration and malformed API origins. Valid IPv6 origins and a trailing root slash have positive coverage.
123 end-to-end scenarios after repairs.
4Parser corpus and transportNo new findings1/2 clean
Reviewed an independent JSON corpus, TLS rejection, decoded transfer limits, signals at the commit boundary and memory instrumentation. No new verified findings.
132 scenarios and 108 AddressSanitizer tests passed. First clean pass.
5Staging ownership1 finding groups0/2 clean
A replaced staging pathname could publish foreign contents. Added inode, metadata and file-extent checks. Cleanup now leaves a foreign replacement untouched and reports it.
136 scenarios after repairs. The clean count reset to zero.
6Ownership and recoveryNo new findings1/2 clean
Followed callback pointers, page arenas, persistent IDs, descriptors, cancellation and crash recovery. No new verified findings.
108 AddressSanitizer tests and 24 filesystem or cancellation scenarios passed.
7Final contract and fresh buildNo new findings2/2 clean
Reviewed CLI behavior, configuration, response rules, permissions, signal exits and both sides of publication. A real Algolia HTTPS request with an invalid key failed without replacing the prior export.
137 scenarios, reported as 139 runner tests, passed. Two consecutive clean passes.
tests/protocol.mjs checks TLS rejection, truncated transfers, decompressed limits, connection reuse and signals at publication syscalls. tests/json-corpus.mjs uses independent JSON and CSV consumers as comparison points.
The review withdrew one apparent short-write finding. A positive return injected with strace skipped the write without creating its claimed prefix. That probe did not represent a real short write, so it is not a regression test or a counted finding.
The environment overrides the local file, including an empty value. The index defaults to products only when unset. A malformed local configuration can still fail startup.
3
Collect and verify
The default query includes founders who code, at least $1 in monthly revenue and a maximum of five pages.
./build/collector
# Tests require Node.js 22+, strace and OpenSSL.
make check
make test
A new CSV uses private permissions. The output directory must already exist. Empty results leave any previous export untouched.
A guide beside the executable.
This page is static HTML, CSS and browser JavaScript. A Node build reads an allowlist of source files and inserts the module excerpts. Caddy serves the published files over HTTPS at indiehacker.plosca.ru.
The guide has no collector backend, account system or analytics. Fonts are hosted here. The diagrams and controls work locally in your browser. A versioned release directory keeps the published guide separate from the working repository.
The source hash identifies the eight Odin files used for these excerpts. The review date and counts describe that recorded verification, not a live monitoring service.