get-gruiks.scm (17009B)
1 ; Copyright (c) 2026, Natacha Porté 2 ; 3 ; Permission to use, copy, modify, and distribute this software for any 4 ; purpose with or without fee is hereby granted, provided that the above 5 ; copyright notice and this permission notice appear in all copies. 6 ; 7 ; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 ; WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 ; MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 ; ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 ; WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 ; ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 ; OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 15 (import 16 (chicken condition) 17 (chicken file) 18 (chicken file posix) 19 (chicken io) 20 (chicken port) 21 (chicken process signal) 22 (chicken process-context) 23 (chicken string) 24 (chicken time) 25 (chicken time posix) 26 atom 27 comparse 28 openssl ; must be above http-client 29 http-client 30 intarweb 31 nanosleep 32 rss 33 sql-de-lite 34 srfi-19-time 35 uri-common) 36 37 (define verbosity 38 (let ((var (get-environment-variable "VERBOSE"))) 39 (if var 40 (let ((n (string->number var))) (if n n 1)) 41 0))) 42 (define (write-log n . args) 43 (when (>= verbosity n) 44 (let ((ts (time->string (seconds->local-time) "%H:%M:%S "))) 45 (write-line (apply conc (cons ts args)))))) 46 47 ;;;;;;;;;;;;;;;;;;;;;;;;;; 48 ;; Scheduling primitives 49 50 (define min-sleep (seconds->time 0.125)) 51 (define (sleep-until deadline) 52 (let* ((dt (time-max min-sleep (time-difference deadline (monotonic-time)))) 53 (sec (time->seconds dt))) 54 (write-log 2 " Sleeping for " (exact->inexact sec) "s") 55 (secosleep sec))) 56 (define (run-until deadline count thunk) 57 (if deadline 58 (let* ((now (monotonic-time)) 59 (my-period (/ (time->seconds (time-difference deadline now)) count)) 60 (my-deadline (add-duration now (seconds->time my-period)))) 61 (thunk) 62 (sleep-until my-deadline)) 63 (thunk))) 64 65 ;;;;;;;;;;;;;;;;;;;;;;;;;;;; 66 ;; Command-Line Processing 67 68 (define arg-list (command-line-arguments)) 69 70 (define db-name 71 (if (>= (length arg-list) 1) 72 (car arg-list) 73 "iens.sqlite")) 74 75 (define total-period 76 (if (>= (length arg-list) 2) 77 (string->number (list-ref arg-list 1)) 78 #f)) 79 80 ;;;;;;;;;;;;;;;;;;;;;;; 81 ;; Persistent Storage 82 83 (define db 84 (open-database db-name)) 85 (exec (sql/transient db "PRAGMA foreign_keys = ON; 86 PRAGMA journal_mode = WAL; 87 PRAGMA synchronous = NORMAL; 88 PRAGMA busy_timeout = 5000;")) 89 (set-busy-handler! db (busy-timeout 10000)) 90 91 (include "common.scm") 92 93 (assert (= 8 (db-version))) 94 95 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 96 ;; Gruik build from sources 97 98 (define gruik-inserted 0) 99 (define gruik-processed 0) 100 101 (define (reset-gruik-counters) 102 (set! gruik-inserted 0) 103 (set! gruik-processed 0)) 104 105 (define (zero-gruik-counters) 106 (unless (and (zero? gruik-inserted) (zero? gruik-processed)) 107 (write-log 0 "Unexpected processing of " 108 gruik-inserted "/" gruik-processed " gruiks") 109 (reset-gruik-counters))) 110 111 (define (process-gruik source url title comm) 112 (set! gruik-processed (add1 gruik-processed)) 113 (when (= 0 (exec (sql db "UPDATE gruik 114 SET lastseen=CAST(strftime('%s', 'now') as INT), 115 comment_url=? 116 WHERE section=? AND url=? AND title=? 117 AND (comment_url IS NULL OR comment_url=?1);") 118 (if comm comm '()) source url title) 119 (query fetch-value 120 (sql db "SELECT count(id) FROM entry 121 WHERE source=? AND url=? AND title=?;") 122 source url title)) 123 (when (= 0 (exec (sql db "UPDATE gruik 124 SET title=?, 125 comment_url=?, 126 notes=trim(notes||char(10) 127 ||'Previously “'||title||'”', 128 char(10)), 129 lastseen=CAST(strftime('%s', 'now') AS INT) 130 WHERE url=? AND section=? 131 AND (comment_url IS NULL OR comment_url=?2);") 132 title (if comm comm '()) url source)) 133 (set! gruik-inserted (add1 gruik-inserted)) 134 (exec 135 (sql db "INSERT INTO gruik(position, notes, ptime, 136 section, url, title, comment_url, 137 mark, ctime, mtime, lastseen) 138 VALUES (-1, '', datetime(?1,'unixepoch')||'*', 139 ?2, ?3, ?4, ?5, 140 ?6, ?1, ?1, CAST(strftime('%s', 'now') as INT));") 141 (query fetch-value 142 (sql db "SELECT MAX(CAST(strftime('%s', 'now') as INT), 143 (SELECT max(mtime) FROM gruik) + 1);")) 144 source url title (if comm comm '()) 145 (if (= 0 (query fetch-value 146 (sql db "SELECT count(id) FROM gruik WHERE url=?;") 147 url) 148 (query fetch-value 149 (sql db "SELECT count(id) FROM entry WHERE url=?;") 150 url)) 151 0 -1))))) 152 153 (define (process-atom deadline source items) 154 (unless (null? items) 155 (run-until deadline (length items) 156 (lambda () 157 (process-gruik source 158 (link-uri (car (entry-links (car items)))) 159 (title-text (entry-title (car items))) 160 #f))) 161 (process-atom deadline source (cdr items)))) 162 163 (define (process-rss deadline source items) 164 (unless (null? items) 165 (let* ((item (car items)) 166 (attr (rss:item-attributes item)) 167 (link (rss:item-link item)) 168 (title (rss:item-title item)) 169 (comm (alist-ref 'comments attr))) 170 (run-until deadline (length items) 171 (lambda () (process-gruik source link (if title title link) comm))) 172 (process-rss deadline source (cdr items))))) 173 174 (define (absorb-304 req parse) 175 (condition-case 176 (with-input-from-request req #f parse) 177 ((exn unexpected-server-response) (values #f #f #f)))) 178 179 (define (get-source parse url last-modified etag) 180 (let* ((hlm (if (null? last-modified) '() 181 `((if-modified-since 182 #(,(seconds->local-time last-modified) ()))))) 183 (het (cond ((null? etag) '()) 184 ((string=? etag "") '()) 185 ((eqv? (string-ref etag 0) #\S) 186 `((if-none-match (strong . ,(substring etag 1))))) 187 ((eqv? (string-ref etag 0) #\W) 188 `((if-none-match (weak . ,(substring etag 1))))) 189 (else '()))) 190 (req (make-request 191 uri: (uri-reference url) 192 headers: (headers `(,@hlm ,@het))))) 193 (let-values (((result _ resp) (absorb-304 req parse))) 194 (when resp 195 (let* ((hdr (response-headers resp)) 196 (lm (header-value 'last-modified hdr)) 197 (et (header-value 'etag hdr))) 198 (when (or (not (null? last-modified)) (not (null? etag)) lm et) 199 (exec (sql db "UPDATE source_rss SET last_modified=?, etag=? 200 WHERE url=?;") 201 (if lm (local-time->seconds lm) '()) 202 (if et (string-append 203 (cond ((eq? (car et) 'weak) "W") 204 ((eq? (car et) 'strong) "S") 205 (else "*")) 206 (cdr et)) 207 '()) 208 url)))) 209 result))) 210 211 (define (atom:read) (read-atom-feed (current-input-port))) 212 (define (get-atom url last-modified etag) 213 (let ((feed (get-source atom:read url last-modified etag))) 214 (if feed 215 (list 1 216 (feed-entries feed) 217 (title-text (feed-title feed))) 218 #f))) 219 220 (define (get-rss url last-modified etag) 221 (let ((feed (get-source rss:read url last-modified etag))) 222 (if feed 223 (list 2 224 (rss:feed-items feed) 225 (rss:item-title (rss:feed-channel feed))) 226 #f))) 227 228 (define (get-auto url) 229 (let* ((data (get-source read-string url '() '())) 230 (da (condition-case (with-input-from-string data atom:read) 231 ((atom) #f))) 232 (dr (condition-case (with-input-from-string data rss:read) 233 ((rss) #f)))) 234 (exec (sql db "UPDATE source_rss SET format=? WHERE url=?;") 235 (cond (da 1) (dr 2) (else -1)) 236 url) 237 (cond 238 (da (list 1 239 (feed-entries da) 240 (title-text (feed-title da)))) 241 (dr (list 2 242 (rss:feed-items dr) 243 (rss:item-title (rss:feed-channel dr)))) 244 (else #f)))) 245 246 (define (process-source deadline name url format last-modified etag) 247 (zero-gruik-counters) 248 (write-log 1 "Processing source " name) 249 (condition-case 250 (let ((data (case format ((0) (get-auto url)) 251 ((1) (get-atom url last-modified etag)) 252 ((2) (get-rss url last-modified etag)) 253 (else #f)))) 254 (if data 255 (let ((args (list 256 (if (and deadline (not (null? deadline))) deadline #f) 257 (if (string=? name url) 258 (begin 259 (exec (sql db "UPDATE source_rss SET name=? 260 WHERE name=? AND url=?;") 261 (caddr data) name url) 262 (caddr data)) 263 name) 264 (cadr data)))) 265 (case (car data) 266 ((1) (apply process-atom args)) 267 ((2) (apply process-rss args)) 268 (else (assert #f "Bad process index"))) 269 (write-log 1 "Inserted " gruik-inserted "/" gruik-processed " gruiks") 270 (reset-gruik-counters)) 271 (exec (sql db "UPDATE gruik 272 SET lastseen=CAST(strftime('%s', 'now') as INT) 273 WHERE section=?;") 274 name))) 275 (exn (client-error) 276 (write-line (conc "Error while checking " name)) 277 (write-line (conc " Headers: " (response-headers ((condition-property-accessor 'client-error 'response) exn)))) 278 (print-error-message exn)) 279 (exn (user-interrupt) (signal exn)) 280 (exn () (write-line (conc "Error while checking " name)) 281 (print-error-message exn)))) 282 283 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 284 ;; Gruik build from IRC log 285 286 (define irc-digit (in #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7 #\8 #\9)) 287 (define irc-hex (in #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7 288 #\8 #\9 #\a #\b #\c #\d #\e #\f)) 289 (define (irc-digits n) (repeated irc-digit n)) 290 (define irc-date 291 (as-string 292 (sequence (irc-digits 4) (is #\.) 293 (irc-digits 2) (is #\.) 294 (irc-digits 2) (is #\ ) 295 (irc-digits 2) (is #\:) 296 (irc-digits 2) (is #\:) 297 (irc-digits 2)))) 298 (define irc-nick 299 (as-string 300 (enclosed-by (is #\<) 301 (repeated item until: (is #\>)) 302 (is #\>)))) 303 (define irc-source 304 (as-string 305 (enclosed-by (char-seq " [") 306 (repeated item until: (is #\])) 307 (char-seq "] ")))) 308 (define irc-url 309 (as-string 310 (enclosed-by (char-seq " ") 311 (sequence (char-seq "http") 312 (repeated item until: (is #\space))) 313 (char-seq " ")))) 314 (define irc-hash 315 (as-string 316 (enclosed-by (char-seq "#") 317 (repeated irc-hex 8) 318 end-of-input))) 319 (define irc-suffix (sequence irc-url irc-hash)) 320 (define irc-line 321 (sequence irc-date 322 irc-nick 323 irc-source 324 (as-string (repeated item until: irc-suffix)) 325 irc-url 326 irc-hash)) 327 328 (define (read-line-pos fd) 329 (let loop ((acc "")) 330 (let ((c (file-read fd 1))) 331 (if (and (= 1 (cadr c)) 332 (not (string=? (car c) "\n"))) 333 (loop (string-append acc (car c))) 334 (list acc (file-position fd)))))) 335 336 (define (line->notes line max-width) 337 (let loop ((rest (string-split line " " #t)) 338 (lines '()) 339 (words "")) 340 (cond 341 ((null? rest) 342 (reverse-string-append (cons words lines))) 343 ((<= (+ (string-length words) 1 (string-length (car rest))) max-width) 344 (loop (cdr rest) 345 lines 346 (string-append words 347 (if (string=? words "") "" " ") 348 (car rest)))) 349 (else 350 (loop (cdr rest) 351 (cons (string-append words "\n") lines) 352 (car rest)))))) 353 354 (define (insert-line line offset) 355 (set! gruik-processed (add1 gruik-processed)) 356 (secosleep (time->seconds (min-sleep))) 357 (and-let* ((parsed (parse irc-line line)) 358 (now (current-seconds)) 359 (section (list-ref parsed 2)) 360 (title (list-ref parsed 3)) 361 (url (list-ref parsed 4)) 362 (_ (= 0 (exec (sql db 363 "UPDATE gruik 364 SET mtime=CAST(strftime('%s', 'now') as INT), 365 notes=(CASE WHEN title=?3 366 THEN notes 367 ELSE trim(notes||char(10) 368 ||'Also “'||?3||'”', 369 char(10)) 370 END) 371 WHERE section=?1 AND url=?2;") 372 section url title) 373 (query fetch-value 374 (sql db "SELECT COUNT(id) FROM entry 375 WHERE source=? AND url=? AND title=?;") 376 section url title)))) 377 (set! gruik-inserted (add1 gruik-inserted)) 378 (exec 379 (sql db 380 "INSERT INTO gruik(position, notes, ptime, 381 section, title, url, mark, ctime, mtime) 382 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);") 383 offset 384 (line->notes line 79) 385 (car parsed) 386 section 387 title 388 url 389 (+ (query fetch-value 390 (sql db "SELECT -2*COUNT(*) FROM gruik WHERE url=?;") 391 url) 392 (query fetch-value 393 (sql db "SELECT -2*COUNT(*) FROM entry WHERE url=?;") 394 url)) 395 now 396 now))) 397 398 (define (import-gruiks) 399 (let ((src-path (get-config "gruik-source"))) 400 (when src-path 401 (let* ((fd (file-open src-path open/rdonly)) 402 (so (get-config/default "gruik-seen" 0)) 403 (_ (set-file-position! fd so seek/set))) 404 (zero-gruik-counters) 405 (write-log 1 "Importing gruiks from " so) 406 (let loop ((offset so)) 407 (let ((rp (read-line-pos fd))) 408 (if (= (cadr rp) offset) 409 (begin 410 (write-log 1 "Imported " gruik-inserted "/" gruik-processed 411 " gruiks until " offset) 412 (reset-gruik-counters) 413 (exec 414 (sql db "INSERT OR REPLACE INTO config VALUES (?,?);") 415 "gruik-seen" 416 offset)) 417 (begin 418 (apply insert-line rp) 419 (loop (cadr rp)))))))))) 420 421 ;;;;;;;;;;;;;;; 422 ;; Actual Run 423 424 (define (source-deadline) 425 (add-duration 426 (monotonic-time) 427 (seconds->time 428 (/ total-period 429 (query fetch-value (sql db "SELECT count(*) FROM source_rss;")))))) 430 431 (define usr1-queue (make-signal-handler signal/usr1)) 432 433 (import-gruiks) 434 435 (if total-period 436 (let loop ((index (query fetch-value 437 (sql/transient db 438 "SELECT min(id) FROM source_rss;")))) 439 (let ((deadline (source-deadline)) 440 (arg (query fetch-row 441 (sql db "SELECT 442 COALESCE((SELECT min(id) FROM source_rss 443 WHERE id > ?1), 444 (SELECT min(id) FROM source_rss)), 445 name,url,format,last_modified,etag 446 FROM source_rss WHERE id = ?1;") 447 index))) 448 (apply process-source (cons deadline (cdr arg))) 449 (sleep-until deadline) 450 (when (<= (car arg) index) 451 (import-gruiks)) 452 (unless (and (<= (car arg) index) (usr1-queue)) 453 (loop (car arg))))) 454 (query 455 (for-each-row* process-source) 456 (sql db "SELECT NULL,name,url,format,last_modified,etag 457 FROM source_rss;")))