code wiki / _hdl_build / nx_domain_map.nx
nx_domain_map.nx source
↩ module page · 671 lines · 34893 B
1// nx_domain_map.nx -- DOMAIN MAP: every URL under a domain BEFORE crawling it -- the /compare/webscraping R8 contracts
2// dm_scan (the composed census) and dm_soft404 (the fingerprint that keeps a 200-that-is-really-404 out of it).
3// Concept source: crawl4ai DomainMapper (eight discovery sources + soft-404 fingerprint). Sovereign form composes the
4// estate's own parsers, never a second one:
5// robots nx_sitemap_extract.sm_robots_sitemaps (Sitemap: lines) + dm_robots_paths (Allow/Disallow paths a site
6// ACKNOWLEDGES exist) ............................................................ source=robots
7// sitemap sm_extract_locs over every sitemap, RECURSING sitemapindex (sm_is_subsitemap) and inflating .gz
8// bodies (nx_gzip_wrap) ........................................................... source=sitemap
9// feed nx_feed_extract on the homepage (feed href -> items) ............................ source=feed
10// wayback the Internet Archive CDX API, original urls, 200s only ............................ source=wayback
11// crt crt.sh certificate-transparency JSON -> subdomains ............................... source=crt (kind=host)
12// probe knowledge/domain_map_probe.conf paths (robots-checked) classified against the SOFT-404 FINGERPRINT taken
13// first from a path that cannot exist ............................................. source=probe
14// EVERY ROW IS APPENDED AS IT IS DECIDED (a run killed at 95 pct keeps 95 pct; the estate's long-sweep law), to
15// knowledge/status/domain_map/<domain>.tsv: ts TAB source TAB kind(url|host|path) TAB verdict TAB value. The final
16// line prints the partition (rows per source) and it SUMS to rows written; fetches used, refused (robots), oversize
17// (a <loc> past the url slot) and truncated (a body that filled the cap) are counted, never silent.
18// PACED: every fetch goes through nx_crawl_pace (per host, Retry-After, robots Crawl-delay); probes are refused when
19// robots forbids the path, and a refusal is a ROW, not a silence.
20// usage: nx_domain_map <domain> [max_fetches] [out.tsv] (rows and caps: knowledge/domain_map.conf)
21// exit: 0 rows written | 2 usage | 3 trust store | 4 nothing reachable (0 fetches succeeded)
22// license_tier: ORIGINAL No hw writes (Rule 26).
23import "nx_syscalls.nx"
24import "nx_lane_conf.nx"
25import "nx_x509_trust_store.nx"
26import "nx_trust_store_load_from_certdata.nx"
27import "nx_https_fetch_follow.nx"
28import "nx_gzip_wrap.nx"
29import "nx_sitemap_extract.nx"
30import "nx_feed_extract.nx"
31import "nx_robots.nx"
32import "nx_crawl_pace.nx"
33import "nx_itoa_lib.nx"
34
35const DM_CONF: *u8 = "knowledge/domain_map.conf"
36const DM_PROBES: *u8 = "knowledge/domain_map_probe.conf"
37const DM_OUTDIR: *u8 = "knowledge/status/domain_map"
38const DM_CERTDATA: *u8 = "data/mozilla_certdata.txt"
39// bootstrap defaults (rule 17: argv > conf > these). Reason per row:
40const DM_DEF_MAX_FETCHES: i64 = 64 // one map is a reconnaissance, not a crawl: 64 fetches covers robots + a sitemap tree + probes
41const DM_DEF_WAYBACK_LIMIT: i64 = 500 // CDX rows requested; the API accepts a limit and 500 keeps the body under the fetch cap
42const DM_DEF_MAX_SITEMAPS: i64 = 32 // sitemap files followed through a sitemapindex before the map says so and stops
43const DM_DEF_MAX_LOCS: i64 = 50000 // sitemaps.org's own per-file ceiling; a file past it is out of spec
44const DM_DEF_MAX_HOSTS: i64 = 256 // distinct subdomains kept from crt.sh; the rest are counted as oversize
45const DM_DEF_FETCH_CAP: i64 = 8388608 // 8 MiB body cap per fetch (announced when filled); crt.sh answers can be MBs
46const DM_DEF_SOFT404_TOL_PERMIL: i64 = 100 // crawl4ai Soft404Fingerprint compares status, title and content length; +-10 pct length
47const DM_DEF_MAX_HOPS: i64 = 6 // redirect hops, the crawler's own number
48const DM_URL_SLOT: i64 = 2048 // one url slot; sitemaps.org allows a 2048-char <loc>, longer is out of spec
49const DM_HOST_SLOT: i64 = 256 // one host slot (a DNS name is at most 253 bytes)
50const DM_PATH_SLOT: i64 = 512 // one probe or robots path slot
51const DM_TS_BUF: i64 = 32
52const DM_LINE_CAP: i64 = 4096
53const DM_SMALL: i64 = 1024
54const DM_TRUST_ANCHORS: i64 = 512 // the crawler's own trust-store sizing
55const DM_TRUST_PARSE_CAP: i64 = 4194304
56const DM_MODE_0644: i64 = 420
57const DM_MODE_0755: i64 = 493
58const DM_GZ_ID1: i64 = 31
59const DM_GZ_ID2: i64 = 139
60const DM_UALEN: i64 = 8 // len("nishibot"), the crawler's UA token
61const DM_TAB: i64 = 9
62const DM_NL: i64 = 10
63const DM_CR: i64 = 13
64const DM_HASH: i64 = 35
65const DM_QUOTE: i64 = 34
66const DM_BACKSLASH: i64 = 92
67const DM_SLASH: i64 = 47
68const DM_COLON: i64 = 58
69const DM_LT: i64 = 60
70const DM_GT: i64 = 62
71const DM_DIGIT0: i64 = 48
72const DM_DIGIT9: i64 = 57
73const DM_HEX_BASE: i64 = 16
74const DM_HASH_SEED: i64 = 5381
75const DM_HASH_MASK: i64 = 0x7fffffffffffffff
76const DM_PROBE_DEFAULTS: i64 = 15
77// verdicts
78const DM_V_DECLARED: *u8 = "DECLARED"
79const DM_V_LISTED: *u8 = "LISTED"
80const DM_V_ARCHIVED: *u8 = "ARCHIVED"
81const DM_V_CERT: *u8 = "CERT"
82const DM_V_LIVE: *u8 = "LIVE"
83const DM_V_SOFT404: *u8 = "SOFT404"
84const DM_V_MISSING: *u8 = "MISSING"
85const DM_V_BLOCKED: *u8 = "BLOCKED"
86const DM_V_UNREACHABLE: *u8 = "UNREACHABLE"
87const DM_V_ROBOTS_REFUSED: *u8 = "ROBOTS-REFUSED"
88const DM_V_FINGERPRINT: *u8 = "FINGERPRINT"
89const DM_HTTP_OK: i64 = 200
90const DM_HTTP_NOTFOUND: i64 = 404
91const DM_HTTP_ERR_FLOOR: i64 = 400
92const DM_STATUS_BUDGET: i64 = 0 - 1 // status-box value meaning: this fetch was refused by OUR budget, the host was never asked
93const DM_V_BUDGET: *u8 = "BUDGET-REFUSED"
94
95static dm_outfd_g: i64
96static dm_fetches_g: i64
97static dm_fetch_ok_g: i64
98static dm_refused_g: i64
99static dm_oversize_g: i64
100static dm_truncated_g: i64
101static dm_rows_robots_g: i64
102static dm_rows_sitemap_g: i64
103static dm_rows_feed_g: i64
104static dm_rows_wayback_g: i64
105static dm_rows_crt_g: i64
106static dm_rows_probe_g: i64
107static dm_rows_total_g: i64
108static dm_max_fetches_g: i64
109static dm_wayback_limit_g: i64
110static dm_max_sitemaps_g: i64
111static dm_max_locs_g: i64
112static dm_max_hosts_g: i64
113static dm_fetch_cap_g: i64
114static dm_tol_g: i64
115static dm_max_hops_g: i64
116static dm_conf_src_g: i64
117// soft-404 fingerprint
118static dm_fp_status_g: i64
119static dm_fp_len_g: i64
120static dm_fp_title_g: i64
121static dm_fp_taken_g: i64
122
123func dm_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
124func dm_puts(s: *u8) -> i64 { sys_write(1, s, dm_len(s)); return 0 }
125func dm_num(v: i64) -> i64 { nxi_out(v); return 0 }
126func dm_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; var p: i64 = o; while s[i] != (0 as u8) { d[p] = s[i]; p = p + 1; i = i + 1 } d[p] = 0 as u8; return p }
127func dm_catn(d: *u8, o: i64, s: *u8, n: i64) -> i64 { var i: i64 = 0; var p: i64 = o; while i < n { d[p] = s[i]; p = p + 1; i = i + 1 } d[p] = 0 as u8; return p }
128func dm_streq(a: *u8, b: *u8) -> i64 { var i: i64 = 0; while a[i] == b[i] { if a[i] == (0 as u8) { return 1 } i = i + 1 } return 0 }
129func dm_lc(c: i64) -> i64 { if c >= 65 { if c <= 90 { return c + 32 } } return c }
130func dm_conf_one(key: *u8, dflt: i64) -> i64 {
131 let v: i64 = lc_geti(DM_CONF, "" as *u8, key, 0 - 1)
132 if v < 0 { return dflt }
133 dm_conf_src_g = 1
134 return v
135}
136func dm_load_conf() -> i64 {
137 dm_conf_src_g = 0
138 dm_max_fetches_g = dm_conf_one("max_fetches" as *u8, DM_DEF_MAX_FETCHES)
139 dm_wayback_limit_g = dm_conf_one("wayback_limit" as *u8, DM_DEF_WAYBACK_LIMIT)
140 dm_max_sitemaps_g = dm_conf_one("max_sitemaps" as *u8, DM_DEF_MAX_SITEMAPS)
141 dm_max_locs_g = dm_conf_one("max_locs_per_sitemap" as *u8, DM_DEF_MAX_LOCS)
142 dm_max_hosts_g = dm_conf_one("max_hosts" as *u8, DM_DEF_MAX_HOSTS)
143 dm_fetch_cap_g = dm_conf_one("fetch_cap_bytes" as *u8, DM_DEF_FETCH_CAP)
144 dm_tol_g = dm_conf_one("soft404_len_tolerance_permil" as *u8, DM_DEF_SOFT404_TOL_PERMIL)
145 dm_max_hops_g = dm_conf_one("max_hops" as *u8, DM_DEF_MAX_HOPS)
146 return dm_conf_src_g
147}
148// djb2 over bytes, masked positive
149func dm_hash(b: *u8, n: i64) -> i64 {
150 var h: i64 = DM_HASH_SEED
151 var i: i64 = 0
152 while i < n { h = (((h << 5) + h) + (b[i] as i64)) & DM_HASH_MASK; i = i + 1 }
153 return h
154}
155// ---- PURE: the soft-404 fingerprint -------------------------------------------------------------------------
156// title hash of an HTML body: hash of the bytes between <title> and </title> (case-insensitive tag), 0 when absent
157func dm_title_hash(html: *u8, n: i64) -> i64 {
158 var i: i64 = 0
159 while i + 7 <= n {
160 if (html[i] as i64) == DM_LT { if dm_lc(html[i + 1] as i64) == 116 { if dm_lc(html[i + 2] as i64) == 105 { if dm_lc(html[i + 3] as i64) == 116 { if dm_lc(html[i + 4] as i64) == 108 { if dm_lc(html[i + 5] as i64) == 101 {
161 var s: i64 = i + 6
162 var f: i64 = 0
163 while f == 0 { if s >= n { f = 1 } else { if (html[s] as i64) == DM_GT { s = s + 1; f = 1 } else { s = s + 1 } } }
164 var e: i64 = s
165 var g: i64 = 0
166 while g == 0 { if e >= n { g = 1 } else { if (html[e] as i64) == DM_LT { g = 1 } else { e = e + 1 } } }
167 if e <= s { return 0 }
168 return dm_hash((html as i64 + s) as *u8, e - s)
169 } } } } } }
170 i = i + 1
171 }
172 return 0
173}
174// 1 when a probe response is the not-found page wearing a 200: same status, same title, length within tolerance.
175// A host whose fingerprint is a real 404 has no soft-404 problem at all (fp_status != 200 -> never SOFT404).
176func dm_soft404_classify(fp_status: i64, fp_len: i64, fp_title: i64, status: i64, len: i64, title: i64, tol_permil: i64) -> i64 {
177 if fp_status != DM_HTTP_OK { return 0 }
178 if status != fp_status { return 0 }
179 if title != fp_title { return 0 }
180 var d: i64 = len - fp_len
181 if d < 0 { d = 0 - d }
182 if d * 1000 <= tol_permil * fp_len { return 1 }
183 return 0
184}
185// THE CONTRACT dm_soft404: classify a probe response against the fingerprint THIS RUN took (dm_source_probe takes it
186// first). Returns 1 SOFT404, 0 not the not-found page, -1 when no fingerprint exists yet (UNOBSERVABLE, never a guess).
187func dm_soft404(status: i64, len: i64, title: i64) -> i64 {
188 if dm_fp_taken_g == 0 { return 0 - 1 }
189 return dm_soft404_classify(dm_fp_status_g, dm_fp_len_g, dm_fp_title_g, status, len, title, dm_tol_g)
190}
191// ---- PURE: robots.txt path mining (Allow/Disallow values are paths the site acknowledges) ---------------------
192func dm_robots_paths(rob: *u8, n: i64, out: *u8, slot: i64, max: i64) -> i64 {
193 var count: i64 = 0
194 var i: i64 = 0
195 while i < n {
196 var at_line: i64 = 0
197 if i == 0 { at_line = 1 } else { if (rob[i - 1] as i64) == DM_NL { at_line = 1 } }
198 if at_line == 1 {
199 var key: i64 = 0
200 if sm_ci_at(rob, i, n, "disallow:" as *u8, 9) == 1 { key = 9 }
201 if sm_ci_at(rob, i, n, "allow:" as *u8, 6) == 1 { key = 6 }
202 if key > 0 {
203 var e: i64 = i
204 while e < n { if (rob[e] as i64) == DM_NL { e = n + e } else { e = e + 1 } }
205 if e > n { e = e - n }
206 if count < max {
207 let dst: *u8 = (out as i64 + count * slot) as *u8
208 let l: i64 = sm_copy_url(rob, i + key, e, dst, slot)
209 // a path row must start with '/', be more than the root and carry no wildcard
210 var ok: i64 = 0
211 if l > 1 { if (dst[0] as i64) == DM_SLASH { ok = 1 } }
212 var k: i64 = 0
213 while k < l { let c: i64 = dst[k] as i64; if c == 42 { ok = 0 } if c == 36 { ok = 0 } k = k + 1 }
214 if ok == 1 {
215 var dup: i64 = 0
216 var d: i64 = 0
217 while d < count { if dm_streq((out as i64 + d * slot) as *u8, dst) == 1 { dup = 1; d = count } else { d = d + 1 } }
218 if dup == 0 { count = count + 1 }
219 }
220 }
221 i = e
222 }
223 }
224 i = i + 1
225 }
226 return count
227}
228// ---- PURE: crt.sh JSON -> distinct hosts (name_value fields, newline-separated, wildcards stripped) -----------
229func dm_crt_hosts(js: *u8, n: i64, domain: *u8, out: *u8, slot: i64, max: i64, oversize: *i64) -> i64 {
230 let key: *u8 = "\"name_value\":\"" as *u8
231 let kl: i64 = dm_len(key)
232 let dl: i64 = dm_len(domain)
233 var count: i64 = 0
234 var i: i64 = 0
235 while i + kl < n {
236 var m: i64 = 1
237 var k: i64 = 0
238 while k < kl { if js[i + k] != key[k] { m = 0; k = kl } else { k = k + 1 } }
239 if m == 1 {
240 var p: i64 = i + kl
241 var hs: i64 = p
242 var f: i64 = 0
243 while f == 0 {
244 var c: i64 = 0
245 var endhost: i64 = 0
246 if p >= n { endhost = 1; f = 1 } else {
247 c = js[p] as i64
248 if c == DM_QUOTE { endhost = 1; f = 1 }
249 if c == DM_BACKSLASH { if p + 1 < n { if (js[p + 1] as i64) == 110 { endhost = 1 } } }
250 }
251 if endhost == 1 {
252 var hl: i64 = p - hs
253 var ho: i64 = hs
254 if hl >= 2 { if (js[ho] as i64) == 42 { if (js[ho + 1] as i64) == 46 { ho = ho + 2; hl = hl - 2 } } }
255 // keep only hosts under the domain (exact or a dot-suffix), lowercase compare
256 var under: i64 = 0
257 if hl >= dl {
258 var eq: i64 = 1
259 var q: i64 = 0
260 while q < dl { if dm_lc(js[ho + hl - dl + q] as i64) != dm_lc(domain[q] as i64) { eq = 0; q = dl } else { q = q + 1 } }
261 if eq == 1 { if hl == dl { under = 1 } else { if (js[ho + hl - dl - 1] as i64) == 46 { under = 1 } } }
262 }
263 if under == 1 { if hl < slot {
264 // lowercase the candidate into the slot past the kept ones (scratch; callers allocate max+1 slots),
265 // dedup against the kept, then commit it or count it as refused past the cap -- a repeat of a
266 // kept host is neither a new host nor a refusal
267 let dst: *u8 = (out as i64 + count * slot) as *u8
268 var w: i64 = 0
269 while w < hl { dst[w] = dm_lc(js[ho + w] as i64) as u8; w = w + 1 }
270 dst[hl] = 0 as u8
271 var dup: i64 = 0
272 var d: i64 = 0
273 while d < count { if dm_streq((out as i64 + d * slot) as *u8, dst) == 1 { dup = 1; d = count } else { d = d + 1 } }
274 if dup == 0 { if count < max { count = count + 1 } else { oversize[0] = oversize[0] + 1 } }
275 } }
276 if f == 0 { p = p + 2; hs = p } else { p = p + 1 }
277 } else { p = p + 1 }
278 }
279 i = p
280 } else { i = i + 1 }
281 }
282 return count
283}
284func dm_is_gzip(b: *u8, n: i64) -> i64 {
285 if n < 2 { return 0 }
286 if (b[0] as i64) == DM_GZ_ID1 { if (b[1] as i64) == DM_GZ_ID2 { return 1 } }
287 return 0
288}
289
290// ---- output rows ------------------------------------------------------------------------------------------------
291func dm_row(source: *u8, kind: *u8, verdict: *u8, value: *u8, vlen: i64) -> i64 {
292 if dm_outfd_g <= 0 { return 0 }
293 let b: *u8 = sys_mmap(DM_LINE_CAP + vlen)
294 var o: i64 = 0
295 o = ccz_cat_num(b, o, sys_now_realtime_sec())
296 b[o] = DM_TAB as u8; o = o + 1
297 o = dm_cat(b, o, source)
298 b[o] = DM_TAB as u8; o = o + 1
299 o = dm_cat(b, o, kind)
300 b[o] = DM_TAB as u8; o = o + 1
301 o = dm_cat(b, o, verdict)
302 b[o] = DM_TAB as u8; o = o + 1
303 o = dm_catn(b, o, value, vlen)
304 b[o] = DM_NL as u8; o = o + 1
305 sys_write(dm_outfd_g, b, o)
306 sys_munmap(b, DM_LINE_CAP + vlen)
307 dm_rows_total_g = dm_rows_total_g + 1
308 return 1
309}
310// ---- paced fetch under the run budget: returns body bytes (0 on failure), status in stbox[0] -------------------
311func dm_fetch(url: *u8, store: *TrustStore, out: *u8, cap: i64, stbox: *i64) -> i64 {
312 stbox[0] = 0
313 // MEASURED on the first live map (python.org, 2026-08-24): the last four probes were budget-refused and
314 // printed UNREACHABLE -- a host verdict for a decision that was ours. A refused fetch says so in the status box.
315 if dm_fetches_g >= dm_max_fetches_g { stbox[0] = DM_STATUS_BUDGET; return 0 }
316 dm_fetches_g = dm_fetches_g + 1
317 let hb: *u8 = sys_mmap(DM_HOST_SLOT)
318 // host = between "://" and the next '/'
319 var i: i64 = 0
320 var s: i64 = 0
321 var f: i64 = 0
322 while f == 0 { if url[i] == (0 as u8) { f = 1 } else { if url[i] == (58 as u8) { if url[i + 1] == (47 as u8) { if url[i + 2] == (47 as u8) { s = i + 3; f = 1 } } } if f == 0 { i = i + 1 } } }
323 var hl: i64 = 0
324 var g: i64 = 0
325 while g == 0 { let c: i64 = url[s + hl] as i64; if c == 0 { g = 1 } else { if c == DM_SLASH { g = 1 } else { if hl + 1 >= DM_HOST_SLOT { g = 1 } else { hb[hl] = url[s + hl]; hl = hl + 1 } } } }
326 hb[hl] = 0 as u8
327 pace_before(hb, hl)
328 let n: i64 = nx_https_fetch_follow_best(url, store, out, cap, dm_max_hops_g, stbox)
329 pace_after(hb, hl, stbox[0], 0)
330 sys_munmap(hb, DM_HOST_SLOT)
331 if n > 0 { dm_fetch_ok_g = dm_fetch_ok_g + 1 }
332 if n >= cap { dm_truncated_g = dm_truncated_g + 1 }
333 if n < 0 { return 0 }
334 return n
335}
336func dm_url(dst: *u8, domain: *u8, path: *u8) -> i64 {
337 var o: i64 = dm_cat(dst, 0, "https://" as *u8)
338 o = dm_cat(dst, o, domain)
339 o = dm_cat(dst, o, path)
340 return o
341}
342// the nonce path no site serves: /nx-dm-<hex of hash(domain)>-does-not-exist
343func dm_nonce_path(dst: *u8, domain: *u8) -> i64 {
344 var o: i64 = dm_cat(dst, 0, "/nx-dm-" as *u8)
345 var h: i64 = dm_hash(domain, dm_len(domain))
346 var k: i64 = 0
347 while k < DM_HEX_BASE { let d: i64 = h % DM_HEX_BASE; if d < 10 { dst[o] = (DM_DIGIT0 + d) as u8 } else { dst[o] = (97 + d - 10) as u8 } o = o + 1; h = h / DM_HEX_BASE; k = k + 1 }
348 o = dm_cat(dst, o, "-does-not-exist" as *u8)
349 return o
350}
351
352// ---- sources ----------------------------------------------------------------------------------------------------
353func dm_source_sitemaps(domain: *u8, store: *TrustStore, robots: *u8, rn: i64, body: *u8, cap: i64, stbox: *i64) -> i64 {
354 // queue of sitemap urls: from robots Sitemap: lines, else /sitemap.xml and /sitemap_index.xml
355 let q: *u8 = sys_mmap(DM_URL_SLOT * (dm_max_sitemaps_g + 2))
356 var qn: i64 = 0
357 if rn > 0 { qn = sm_robots_sitemaps(robots, rn, q, DM_URL_SLOT, dm_max_sitemaps_g) }
358 if qn == 0 {
359 dm_url(q, domain, "/sitemap.xml" as *u8)
360 dm_url((q as i64 + DM_URL_SLOT) as *u8, domain, "/sitemap_index.xml" as *u8)
361 qn = 2
362 }
363 let locs: *u8 = sys_mmap(DM_URL_SLOT * (dm_max_locs_g + 1))
364 var qi: i64 = 0
365 var emitted: i64 = 0
366 while qi < qn {
367 let su: *u8 = (q as i64 + qi * DM_URL_SLOT) as *u8
368 var n: i64 = dm_fetch(su, store, body, cap, stbox)
369 var xml: *u8 = body
370 var xn: i64 = n
371 var gzres: *NxGzipResult = 0 as *NxGzipResult
372 if n > 0 { if dm_is_gzip(body, n) == 1 {
373 gzres = nx_gzip_inflate(body, n, cap)
374 if gzres.error_code == 0 { xml = gzres.output_data; xn = gzres.output_size } else { xn = 0 }
375 } }
376 if xn > 0 { if stbox[0] == DM_HTTP_OK {
377 let nl: i64 = sm_extract_locs(xml, xn, locs, DM_URL_SLOT, dm_max_locs_g)
378 if nl >= dm_max_locs_g { dm_oversize_g = dm_oversize_g + 1 }
379 var li: i64 = 0
380 while li < nl {
381 let lp: *u8 = (locs as i64 + li * DM_URL_SLOT) as *u8
382 let ll: i64 = dm_len(lp)
383 if sm_is_subsitemap(lp, ll) == 1 {
384 var seen: i64 = 0
385 var z: i64 = 0
386 while z < qn { if dm_streq((q as i64 + z * DM_URL_SLOT) as *u8, lp) == 1 { seen = 1; z = qn } else { z = z + 1 } }
387 if seen == 0 { if qn < dm_max_sitemaps_g { dm_cat((q as i64 + qn * DM_URL_SLOT) as *u8, 0, lp); qn = qn + 1 } else { dm_oversize_g = dm_oversize_g + 1 } }
388 } else {
389 dm_row("sitemap" as *u8, "url" as *u8, DM_V_LISTED, lp, ll)
390 dm_rows_sitemap_g = dm_rows_sitemap_g + 1
391 emitted = emitted + 1
392 }
393 li = li + 1
394 }
395 } }
396 qi = qi + 1
397 }
398 sys_munmap(locs, DM_URL_SLOT * (dm_max_locs_g + 1))
399 sys_munmap(q, DM_URL_SLOT * (dm_max_sitemaps_g + 2))
400 return emitted
401}
402func dm_source_feed(domain: *u8, store: *TrustStore, body: *u8, cap: i64, stbox: *i64, feedxml: *u8) -> i64 {
403 let home: *u8 = sys_mmap(DM_URL_SLOT)
404 dm_url(home, domain, "/" as *u8)
405 let n: i64 = dm_fetch(home, store, body, cap, stbox)
406 if n <= 0 { return 0 }
407 let fh: *u8 = sys_mmap(DM_URL_SLOT)
408 if nx_feed_discover(body, n, fh, DM_URL_SLOT) != 1 { return 0 }
409 // absolute or root-relative feed href
410 let fu: *u8 = sys_mmap(DM_URL_SLOT)
411 if (fh[0] as i64) == DM_SLASH { dm_url(fu, domain, fh) } else { dm_cat(fu, 0, fh) }
412 let fn: i64 = dm_fetch(fu, store, feedxml, cap, stbox)
413 if fn <= 0 { return 0 }
414 let t: *u8 = sys_mmap(DM_URL_SLOT)
415 let l: *u8 = sys_mmap(DM_URL_SLOT)
416 let sm: *u8 = sys_mmap(DM_URL_SLOT)
417 var idx: i64 = 0
418 var emitted: i64 = 0
419 var go: i64 = 1
420 while go == 1 {
421 if nx_feed_item_at(feedxml, fn, idx, t, DM_URL_SLOT, l, DM_URL_SLOT, sm, DM_URL_SLOT) == 1 {
422 let ll: i64 = dm_len(l)
423 if ll > 0 { dm_row("feed" as *u8, "url" as *u8, DM_V_LISTED, l, ll); dm_rows_feed_g = dm_rows_feed_g + 1; emitted = emitted + 1 }
424 idx = idx + 1
425 } else { go = 0 }
426 }
427 return emitted
428}
429func dm_source_wayback(domain: *u8, store: *TrustStore, body: *u8, cap: i64, stbox: *i64) -> i64 {
430 let u: *u8 = sys_mmap(DM_URL_SLOT)
431 var o: i64 = dm_cat(u, 0, "https://web.archive.org/cdx/search/cdx?url=" as *u8)
432 o = dm_cat(u, o, domain)
433 o = dm_cat(u, o, "/*&fl=original&collapse=urlkey&filter=statuscode:200&limit=" as *u8)
434 o = ccz_cat_num(u, o, dm_wayback_limit_g)
435 let n: i64 = dm_fetch(u, store, body, cap, stbox)
436 if n <= 0 { return 0 }
437 if stbox[0] != DM_HTTP_OK { return 0 }
438 var emitted: i64 = 0
439 var s: i64 = 0
440 while s < n {
441 var e: i64 = s
442 while e < n { if (body[e] as i64) == DM_NL { e = n + e } else { e = e + 1 } }
443 if e > n { e = e - n }
444 var e2: i64 = e
445 if e2 > s { if (body[e2 - 1] as i64) == DM_CR { e2 = e2 - 1 } }
446 if e2 > s { dm_row("wayback" as *u8, "url" as *u8, DM_V_ARCHIVED, (body as i64 + s) as *u8, e2 - s); dm_rows_wayback_g = dm_rows_wayback_g + 1; emitted = emitted + 1 }
447 s = e + 1
448 }
449 return emitted
450}
451func dm_source_crt(domain: *u8, store: *TrustStore, body: *u8, cap: i64, stbox: *i64) -> i64 {
452 let u: *u8 = sys_mmap(DM_URL_SLOT)
453 var o: i64 = dm_cat(u, 0, "https://crt.sh/?q=%25." as *u8)
454 o = dm_cat(u, o, domain)
455 o = dm_cat(u, o, "&output=json" as *u8)
456 let n: i64 = dm_fetch(u, store, body, cap, stbox)
457 if n <= 0 { return 0 }
458 if stbox[0] != DM_HTTP_OK { return 0 }
459 let hosts: *u8 = sys_mmap(DM_HOST_SLOT * (dm_max_hosts_g + 1))
460 let ov: *i64 = sys_mmap(16) as *i64
461 ov[0] = 0
462 let nh: i64 = dm_crt_hosts(body, n, domain, hosts, DM_HOST_SLOT, dm_max_hosts_g, ov)
463 dm_oversize_g = dm_oversize_g + ov[0]
464 var i: i64 = 0
465 while i < nh {
466 let hp: *u8 = (hosts as i64 + i * DM_HOST_SLOT) as *u8
467 dm_row("crt" as *u8, "host" as *u8, DM_V_CERT, hp, dm_len(hp))
468 dm_rows_crt_g = dm_rows_crt_g + 1
469 i = i + 1
470 }
471 sys_munmap(hosts, DM_HOST_SLOT * (dm_max_hosts_g + 1))
472 return nh
473}
474// probes: the fingerprint first, then every conf path robots allows
475func dm_probe_paths_load(out: *u8, slot: i64, max: i64) -> i64 {
476 let lb: *i64 = sys_mmap(16) as *i64
477 lb[0] = 0
478 let src: *u8 = sys_read_file(DM_PROBES, lb)
479 var count: i64 = 0
480 if (src as i64) != 0 {
481 let n: i64 = lb[0]
482 var s: i64 = 0
483 while s < n {
484 var e: i64 = s
485 while e < n { if (src[e] as i64) == DM_NL { e = n + e } else { e = e + 1 } }
486 if e > n { e = e - n }
487 var e2: i64 = e
488 if e2 > s { if (src[e2 - 1] as i64) == DM_CR { e2 = e2 - 1 } }
489 if e2 > s { if (src[s] as i64) == DM_SLASH { if count < max { if e2 - s < slot {
490 dm_catn((out as i64 + count * slot) as *u8, 0, (src as i64 + s) as *u8, e2 - s)
491 count = count + 1
492 } } } }
493 s = e + 1
494 }
495 sys_free_file(src, n)
496 return count
497 }
498 // bootstrap defaults: the well-known paths crawl4ai's DomainMapper probes, trimmed to the ones a public site serves
499 let d0: *u8 = "/docs" as *u8
500 let d1: *u8 = "/api" as *u8
501 let d2: *u8 = "/login" as *u8
502 let d3: *u8 = "/dashboard" as *u8
503 let d4: *u8 = "/openapi.json" as *u8
504 let d5: *u8 = "/sitemap.xml" as *u8
505 let d6: *u8 = "/feed" as *u8
506 let d7: *u8 = "/rss" as *u8
507 let d8: *u8 = "/atom.xml" as *u8
508 let d9: *u8 = "/blog" as *u8
509 let d10: *u8 = "/about" as *u8
510 let d11: *u8 = "/status" as *u8
511 let d12: *u8 = "/admin" as *u8
512 let d13: *u8 = "/graphql" as *u8
513 let d14: *u8 = "/.well-known/security.txt" as *u8
514 var c: i64 = 0
515 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d0); c = c + 1 }
516 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d1); c = c + 1 }
517 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d2); c = c + 1 }
518 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d3); c = c + 1 }
519 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d4); c = c + 1 }
520 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d5); c = c + 1 }
521 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d6); c = c + 1 }
522 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d7); c = c + 1 }
523 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d8); c = c + 1 }
524 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d9); c = c + 1 }
525 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d10); c = c + 1 }
526 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d11); c = c + 1 }
527 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d12); c = c + 1 }
528 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d13); c = c + 1 }
529 if c < max { dm_cat((out as i64 + c * slot) as *u8, 0, d14); c = c + 1 }
530 return c
531}
532func dm_source_probe(domain: *u8, store: *TrustStore, robots: *u8, rn: i64, body: *u8, cap: i64, stbox: *i64) -> i64 {
533 let u: *u8 = sys_mmap(DM_URL_SLOT)
534 let p: *u8 = sys_mmap(DM_PATH_SLOT)
535 // 1. the fingerprint: a path that cannot exist
536 let pl: i64 = dm_nonce_path(p, domain)
537 dm_url(u, domain, p)
538 let fn: i64 = dm_fetch(u, store, body, cap, stbox)
539 dm_fp_status_g = stbox[0]
540 dm_fp_len_g = fn
541 dm_fp_title_g = 0
542 if fn > 0 { dm_fp_title_g = dm_title_hash(body, fn) }
543 if stbox[0] > 0 { dm_fp_taken_g = 1 }
544 let fpline: *u8 = sys_mmap(DM_SMALL)
545 var fo: i64 = dm_cat(fpline, 0, "status=" as *u8)
546 fo = ccz_cat_num(fpline, fo, dm_fp_status_g)
547 fo = dm_cat(fpline, fo, " len=" as *u8)
548 fo = ccz_cat_num(fpline, fo, dm_fp_len_g)
549 fo = dm_cat(fpline, fo, " title_hash=" as *u8)
550 fo = ccz_cat_num(fpline, fo, dm_fp_title_g)
551 fo = dm_cat(fpline, fo, " path=" as *u8)
552 fo = dm_cat(fpline, fo, p)
553 dm_row("probe" as *u8, "path" as *u8, DM_V_FINGERPRINT, fpline, fo)
554 dm_rows_probe_g = dm_rows_probe_g + 1
555 // 2. the paths
556 let paths: *u8 = sys_mmap(DM_PATH_SLOT * (DM_PROBE_DEFAULTS * 4))
557 let np: i64 = dm_probe_paths_load(paths, DM_PATH_SLOT, DM_PROBE_DEFAULTS * 4)
558 var i: i64 = 0
559 var emitted: i64 = 1
560 while i < np {
561 let pp: *u8 = (paths as i64 + i * DM_PATH_SLOT) as *u8
562 let ppl: i64 = dm_len(pp)
563 var allowed: i64 = 1
564 if rn > 0 { allowed = nx_robots_allowed(robots, rn, "nishibot" as *u8, DM_UALEN, pp, ppl) }
565 if allowed == 0 {
566 dm_row("probe" as *u8, "path" as *u8, DM_V_ROBOTS_REFUSED, pp, ppl)
567 dm_refused_g = dm_refused_g + 1
568 } else {
569 dm_url(u, domain, pp)
570 let n: i64 = dm_fetch(u, store, body, cap, stbox)
571 var v: *u8 = DM_V_UNREACHABLE
572 if stbox[0] == DM_STATUS_BUDGET { v = DM_V_BUDGET }
573 if stbox[0] > 0 {
574 if stbox[0] == DM_HTTP_OK {
575 var th: i64 = 0
576 if n > 0 { th = dm_title_hash(body, n) }
577 if dm_soft404_classify(dm_fp_status_g, dm_fp_len_g, dm_fp_title_g, stbox[0], n, th, dm_tol_g) == 1 { v = DM_V_SOFT404 } else { v = DM_V_LIVE }
578 } else {
579 if stbox[0] == DM_HTTP_NOTFOUND { v = DM_V_MISSING } else { if stbox[0] >= DM_HTTP_ERR_FLOOR { v = DM_V_BLOCKED } else { v = DM_V_LIVE } }
580 }
581 }
582 dm_row("probe" as *u8, "path" as *u8, v, pp, ppl)
583 }
584 dm_rows_probe_g = dm_rows_probe_g + 1
585 emitted = emitted + 1
586 i = i + 1
587 }
588 return emitted
589}
590
591// THE CONTRACT dm_scan: the composed census -- robots, sitemaps, feed, wayback, crt, probes -- in that order, every row
592// appended as it is decided. Returns the rows written; the caller prints the partition (which must sum).
593func dm_scan(domain: *u8, store: *TrustStore, body: *u8, feedxml: *u8, robots: *u8, cap: i64, stbox: *i64) -> i64 {
594 let ru: *u8 = sys_mmap(DM_URL_SLOT)
595 dm_url(ru, domain, "/robots.txt" as *u8)
596 var rn: i64 = dm_fetch(ru, store, robots, cap, stbox)
597 if stbox[0] != DM_HTTP_OK { rn = 0 }
598 if rn > 0 {
599 let paths: *u8 = sys_mmap(DM_PATH_SLOT * (dm_max_hosts_g + 1))
600 let np: i64 = dm_robots_paths(robots, rn, paths, DM_PATH_SLOT, dm_max_hosts_g)
601 var i: i64 = 0
602 while i < np { let pp: *u8 = (paths as i64 + i * DM_PATH_SLOT) as *u8; dm_row("robots" as *u8, "path" as *u8, DM_V_DECLARED, pp, dm_len(pp)); dm_rows_robots_g = dm_rows_robots_g + 1; i = i + 1 }
603 dm_puts(" robots.txt bytes=" as *u8); dm_num(rn); dm_puts(" declared_paths=" as *u8); dm_num(np); dm_puts("\n" as *u8)
604 } else { dm_puts(" robots.txt absent or unreadable (status=" as *u8); dm_num(stbox[0]); dm_puts(") -- sitemaps guessed, probes unrestricted per RFC 9309\n" as *u8) }
605 let ns: i64 = dm_source_sitemaps(domain, store, robots, rn, body, cap, stbox)
606 dm_puts(" sitemap urls=" as *u8); dm_num(ns); dm_puts("\n" as *u8)
607 let nf: i64 = dm_source_feed(domain, store, body, cap, stbox, feedxml)
608 dm_puts(" feed urls=" as *u8); dm_num(nf); dm_puts("\n" as *u8)
609 let nw: i64 = dm_source_wayback(domain, store, body, cap, stbox)
610 dm_puts(" wayback urls=" as *u8); dm_num(nw); dm_puts(" last_status=" as *u8); dm_num(stbox[0]); dm_puts("\n" as *u8)
611 let nc: i64 = dm_source_crt(domain, store, body, cap, stbox)
612 dm_puts(" crt hosts=" as *u8); dm_num(nc); dm_puts(" last_status=" as *u8); dm_num(stbox[0]); dm_puts("\n" as *u8)
613 let npb: i64 = dm_source_probe(domain, store, robots, rn, body, cap, stbox)
614 dm_puts(" probe rows=" as *u8); dm_num(npb); dm_puts(" fingerprint status=" as *u8); dm_num(dm_fp_status_g); dm_puts(" len=" as *u8); dm_num(dm_fp_len_g); dm_puts("\n" as *u8)
615 return dm_rows_total_g
616}
617
618func main(argc: i64, argv: *i64) -> i64 {
619 if argc < 2 { dm_puts("usage: nx_domain_map <domain> [max_fetches] [out.tsv]\n" as *u8); return 2 }
620 let domain: *u8 = argv[1] as *u8
621 dm_load_conf()
622 if argc >= 3 { let mf: i64 = wc_atoi_dm(argv[2] as *u8); if mf > 0 { dm_max_fetches_g = mf } }
623 let r: i64 = nx_trust_store_load_from_certdata(DM_CERTDATA, DM_TRUST_ANCHORS, DM_TRUST_PARSE_CAP)
624 if r <= 0 { dm_puts("DOMAIN-MAP UNMEASURED: trust store failed to load\n" as *u8); return 3 }
625 let store: *TrustStore = r as *TrustStore
626 // output: knowledge/status/domain_map/<domain>.tsv, appended
627 sys_mkdir(DM_OUTDIR, DM_MODE_0755)
628 let outp: *u8 = sys_mmap(DM_URL_SLOT)
629 if argc >= 4 { dm_cat(outp, 0, argv[3] as *u8) } else {
630 var o: i64 = dm_cat(outp, 0, DM_OUTDIR)
631 o = dm_cat(outp, o, "/" as *u8)
632 o = dm_cat(outp, o, domain)
633 o = dm_cat(outp, o, ".tsv" as *u8)
634 }
635 dm_outfd_g = sys_openat_append(outp, DM_MODE_0644)
636 if dm_outfd_g <= 0 { dm_puts("DOMAIN-MAP UNMEASURED: cannot open " as *u8); dm_puts(outp); dm_puts("\n" as *u8); return 3 }
637 dm_puts("=== nx_domain_map " as *u8); dm_puts(domain); dm_puts(" -> " as *u8); dm_puts(outp)
638 dm_puts(" (max_fetches=" as *u8); dm_num(dm_max_fetches_g); dm_puts(" conf_src=" as *u8)
639 if dm_conf_src_g == 1 { dm_puts("file" as *u8) } else { dm_puts("defaults" as *u8) }
640 dm_puts(")\n" as *u8)
641 let cap: i64 = dm_fetch_cap_g
642 let body: *u8 = sys_mmap(cap)
643 let feedxml: *u8 = sys_mmap(cap)
644 let robots: *u8 = sys_mmap(cap)
645 let stbox: *i64 = sys_mmap(16) as *i64
646 // THE CONTRACT: one composed census, rows appended as decided
647 let scanned: i64 = dm_scan(domain, store, body, feedxml, robots, cap, stbox)
648 dm_puts(" scanned rows=" as *u8); dm_num(scanned); dm_puts("\n" as *u8)
649 // THE PARTITION, and it sums
650 let sum: i64 = dm_rows_robots_g + dm_rows_sitemap_g + dm_rows_feed_g + dm_rows_wayback_g + dm_rows_crt_g + dm_rows_probe_g
651 dm_puts("DOMAIN-MAP " as *u8); dm_puts(domain)
652 dm_puts(" rows=" as *u8); dm_num(dm_rows_total_g)
653 dm_puts(" robots=" as *u8); dm_num(dm_rows_robots_g)
654 dm_puts(" sitemap=" as *u8); dm_num(dm_rows_sitemap_g)
655 dm_puts(" feed=" as *u8); dm_num(dm_rows_feed_g)
656 dm_puts(" wayback=" as *u8); dm_num(dm_rows_wayback_g)
657 dm_puts(" crt=" as *u8); dm_num(dm_rows_crt_g)
658 dm_puts(" probe=" as *u8); dm_num(dm_rows_probe_g)
659 if sum == dm_rows_total_g { dm_puts(" partition=SUMS" as *u8) } else { dm_puts(" partition=LEAK" as *u8) }
660 dm_puts(" fetches=" as *u8); dm_num(dm_fetches_g); dm_puts("/" as *u8); dm_num(dm_max_fetches_g)
661 dm_puts(" fetch_ok=" as *u8); dm_num(dm_fetch_ok_g)
662 dm_puts(" robots_refused=" as *u8); dm_num(dm_refused_g)
663 dm_puts(" oversize=" as *u8); dm_num(dm_oversize_g)
664 dm_puts(" truncated=" as *u8); dm_num(dm_truncated_g)
665 dm_puts(" soft404_fingerprint=" as *u8); if dm_fp_taken_g == 1 { dm_puts("TAKEN" as *u8) } else { dm_puts("UNTAKEN" as *u8) }
666 dm_puts("\n" as *u8)
667 sys_close(dm_outfd_g)
668 if dm_fetch_ok_g == 0 { dm_puts("DOMAIN-MAP UNREACHABLE (no fetch succeeded)\n" as *u8); return 4 }
669 return 0
670}
671func wc_atoi_dm(s: *u8) -> i64 { var v: i64 = 0; var i: i64 = 0; while s[i] != (0 as u8) { let c: i64 = s[i] as i64; if c >= DM_DIGIT0 { if c <= DM_DIGIT9 { v = v * 10 + (c - DM_DIGIT0) } } i = i + 1 } return v }