root/plugin/captcha-booru.scm

(define-module (plugin captcha-booru)
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-26)           ;partial application
  #:use-module (ice-9 regex)
  #:use-module (sxml-tolerant simple)
  #:use-module (crypto)
  #:use-module (utils)
  #:use-module (web client)
  #:use-module (webserver-utils)
  #:use-module (html)
  #:use-module (download)
  #:use-module (plugin captcha-interface)
  #:export (captcha-touhou
            captcha-touhou-no-sample))

(define all-touhous
  '("alice_margatroid" "shameimaru_aya" "tsukumo_benben" "hijiri_byakuren" "chen"
    "cirno" "clownpiece" "yagokoro_eirin" "elis_(touhou)" "ellen_(touhou)"
    "elly" "flandre_scarlet" "fujiwara_no_mokou" "gengetsu" "genjii" ;; "goliath_doll"
    "hata_no_kokoro" "himekaidou_hatate" "hecatia_lapislazuli" "hieda_no_akyu"
    "kagiyama_hina" "hong_meiling" ;; "hourai_doll"
    "kumoi_ichirin" "nagae_iku" "junko_(touhou)" "imaizumi_kagerou"
    "houraisan_kaguya" "anaberal_kana" "yasaka_kanako" "ibaraki_kasen"
    "keine_kamishirasawa" "kikuri_(touhou)" "kisume" "koakuma" "tatara_kogasa"
    "komeiji_koishi" "onozuka_komachi" "konngara" "motoori_kosuzu" "kotohime"
    "kurumi_(touhou)" "kasodani_kyouko" "layla_prismriver" "letty_whiterock"
    "lily_white" "louise_(touhou)" "luna_child" "lunasa_prismriver"
    "lyrica_prismriver" "mai_(touhou)" "futatsuiwa_mamizou" "maribel_hearn"
    "kirisame_marisa" "medicine_melancholy" "meira" "merlin_prismriver" "mima"
    "murasa_minamitsu" "aki_minoriko" "mishaguji" "inubashiri_momiji"
    "mononobe_no_futo" "mugetsu" ;; "hijiri_myouren"
    "mystia_lorelei" "nazrin" "kawashiro_nitori" "orange_(touhou)"
    "mizuhashi_parsee" "patchouli_knowledge" "horikawa_raiko" "yakumo_ran"
    "hakurei_reimu" "remilia_scarlet" "usami_renko" "rika_(touhou)"
    "asakura_rikako" "kaenbyou_rin" "satsuki_rin" "ringo_(touhou)" ;; "rinnosuke_morichika"
    "ruukoto" "kishin_sagume" "izayoi_sakuya" "kochiya_sanae" "sara_(touhou)"
    "sariel" "komeiji_satori" "kaku_seiga" "kijin_seija" "seiran_(touhou)"
    "sekibanki" ;; "shanghai_doll"
    "shiki_eiki" "shingyoku" "shinki" "shinmyoumaru_sukuna"
    "aki_shizuha" "toramaru_shou" "soga_no_tojiko" "sokrates_(touhou)"
    "star_sapphire" "ibuki_suika" "usami_sumireko" "sunny_milk" "moriya_suwako"
    "hinanawi_tenshi" "inaba_tewi" "tokiko_(touhou)" "toyosatomimi_no_miko"
    "unzan" "reiuji_utsuho" "wakasagihime" "watatsuki_no_toyohime"
    "watatsuki_no_yorihime" "wriggle_nightbug" "kurodani_yamame"
    "tsukumo_yatsuhashi" "miyako_yoshika" ;; "konpaku_youki"
    "konpaku_youmu" "yakumo_yukari" "yuki_(touhou)" "yumeko" "okazaki_yumemi"
    "yuugenmagan" "hoshiguma_yuugi" "kazami_yuuka" "saigyouji_yuyuko"))

(define store-filepath "/var/www/captcha/private/booru-store.alist")
(define protocol "http:")                ;TODO https
(define *store* (or (read-file store-filepath) '()))


(define* (populate #:optional (url "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=0&tags=1girl%20touhou"))
  (define store (or (read-file store-filepath) '()))
  (call-with-values (lambda () (http-get url))
    (lambda (response body)
      (define page (xml->sxml body))
      (define post-tags (html-match page '(post)))
      (define new-data (delete-duplicates
                        (append (filter identity (map handle-post post-tags))
                                store)))
      (write-file store-filepath new-data)
      (set! *store* new-data))))

(define (handle-post post)
  (define tags (cadr (html-match1 post '(tags))))
  (define preview-url (cadr (html-match1 post '(preview_url))))
  (define original-url (cadr (html-match1 post '(file_url))))
  (define (has-tag? tag)
    (string-match (string-append "\\b" tag "\\b") tags))
  (define known-touhou
    (and (not (has-tag? "cosplay"))
         (not (has-tag? "comic"))
         (find (lambda (touhou)
                 (has-tag? touhou)) all-touhous)))
  (if known-touhou
      (download-preview preview-url original-url tags known-touhou)
      #f))

(define (download-preview url original-url tags main-touhou)
  (define filename (basename url))
  ;; TODO (get-setting 'download-path)
  (and (download (string-append "/var/www/captcha/file/" filename)
                 (string-append protocol url))
       `((main-touhou . ,main-touhou)
         (filename . ,filename)
         (url . ,url)
         (original-url . ,original-url)
         (tags . ,tags))))


(define (generate-challenge)
  (define pool (map-n-times (lambda (i) (pick-random *store*)) 9))
  (define chosen-touhou (pick-random pool))
  (define sample (or (find (lambda (touhou)
                             (and (not (eq? touhou chosen-touhou))
                                  (equal? (assoc-ref touhou 'main-touhou)
                                          (assoc-ref chosen-touhou 'main-touhou))))
                           *store*)
                     ;; don't mind duplicates if there is only one entry in the store
                     chosen-touhou))
  (define expected (map (lambda (touhou)
                          (equal? (assoc-ref touhou 'main-touhou)
                                  (assoc-ref chosen-touhou 'main-touhou)))
                        pool))
  `((sample . ,sample)
    (pool . ,pool)
    (expected . ,expected)
    (id . ,(random-string-in-collection 15 char-collection-alpha-numeric))))

(define (choice-name challenge i)
  (string-append (assoc-ref challenge 'id) "-" (number->string i)))

(define* (render-challenge challenge #:optional (show-sample #t))
  (define sample (assoc-ref challenge 'sample))
  (define pool (assoc-ref challenge 'pool))
  (define touhou-name (string-join
                       (map (lambda (word)
                              (string-upcase word 0 1))
                            (string-split (assoc-ref sample 'main-touhou) #\_))
                       " "))
  (define i 0)
  `((style "
.capcha.touhou-intro {width: 300px; 76px; background: #f9f9f9; border-radius: 3px;}
.capcha-touhou-intro-text {margin-bottom: 3px;}

.captcha.touhou {width:400px; border: 1px solid #ccc; font-family: Roboto,helvetica,arial,sans-serif;
 box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 3px;}
.captcha.touhou .sample-row {display: flex; padding-left: 12px; padding-right: 12px; padding-top: 12px;}
.captcha.touhou .sample-explain-text {padding-top: 14px;}
.captcha.touhou label {position: relative; display:inline-block;}
.captcha.touhou label input {position: absolute; top: 3px; left: 3px; transition: all 0.1s;}
.captcha.touhou label img { transition: all 0.1s;}
.captcha.touhou .pick-images {padding-left: 12px; padding-right: 12px; padding-bottom: 14px; padding-top: 16px;}
.captcha.touhou .action-buttons {display:flex; border-top: 1px solid #dfdfdf; padding: 20px; padding-top: 15px;
 justify-content: space-between;}
.captcha.touhou .verify {background: #4a90e2; color: #fffffa; border: 0; border-radius: 2px;
 cursor: pointer; font-size: 12px; font-weight: 500; height: 34px; line-height: 34px;
 padding: 0px 10px 0px 10px; text-align: center; text-transform: uppercase; }
.captcha.touhou .misc-buttons button {background: none; border: none; font-size: 20px; padding-top: 6px}

.captcha.touhou input { background: none; -webkit-appearance: none; -moz-appearance: none; -o-appearance: none; appearance: none; outline:0;}
.captcha.touhou input:checked { background: #4480f7; width: 20px; height: 20px; appearance: none; border-radius: 50px; border: 5px solid #4480f7;
     }
.captcha.touhou input:checked:after { content: '\\2714'; color: white; font-size: 20px; position: absolute; top: -6px; left: -2px; }
.captcha.touhou input:checked ~ img { width:100px; height:100px; padding: 12px;}
")
    (p (@ (class "capcha-touhou-intro-text"))
       "Please proof that you are sufficently versed in touhous:")
    (div (@ (class "captcha touhou"))
         (div (@ (class "sample-row"))
              (div (@ (class "sample-explain-text"))
                   ,(string-append "Check all images of " touhou-name ".")
                   ,(if show-sample
                        (string-append " A sample image of " touhou-name
                                       " is on the right.")
                        '()))
              ,(if show-sample
                   `(img (@ (alt ,touhou-name)
                            (width "100")
                            (height "100")
                            (src ,(string-append
                                   "/file/"
                                   (assoc-ref sample 'filename)))))
                   '()))
         (div (@ (class "pick-images"))
              ,@(map (lambda (touhou)
                       (define result
                         `(label
                           (input (@ (name ,(choice-name challenge i))
                                     (value "y")
                                     (type "checkbox")))
                           (img (@ (alt "touhou for captcha")
                                   (width "124")
                                   (height "124")
                                   (src ,(string-append
                                          "/file/" (assoc-ref touhou 'filename)))))))
                       (set! i (+ 1 i))
                       result)
                     pool))
         (div (@ (class "action-buttons"))
              (div (@ (class "misc-buttons"))
                   (button (@ (class "refresh")) "↻")
                   (button (@ (class "audio")) "🎧")
                   (button (@ (class "info")) "🛈"))
              (button (@ (class "verify")) "Verify")))))

(define (validate-input challenge get-form-field)
  (define i 0)
  (define (check i)
    (equal? "y" (get-form-field (choice-name challenge i))))
  (not (find (lambda (x) (eq? 'wrong x))
             (map (lambda (expect)
                    (define checked (check i))
                    (define result (or (and (eqv? #f expect) (not checked))
                                       (and (eqv? #t expect) checked)
                                       'wrong))
                    (set! i (+ 1 i))
                    result)
                  (assoc-ref challenge 'expected)))))

(define captcha-touhou
  (make-captcha-plugin 'captcha-touhou
                       generate-challenge
                       render-challenge
                       validate-input))

(define captcha-touhou-no-sample
  (make-captcha-plugin 'captcha-touhou-no-sample
                       generate-challenge
                       (cut render-challenge <> #f)
                       validate-input))

;;; run with guile -L . -e populate-touhou ./plugin/captcha-booru
(define (populate-touhou)
  (populate)
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=1&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=2&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=3&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=4&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=5&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=6&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=7&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=8&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=9&tags=1girl%20touhou")
  (populate "http://safebooru.org//index.php?page=dapi&s=post&q=index&pid=10&tags=1girl%20touhou"))