henry_flower: A melancholy wolf (Default)
henry_flower ([personal profile] henry_flower) wrote2024-08-03 01:20 pm

Найпримітивніший link checker

Якщо набрати link checker ув гооглі, то виявиться що перевірка посилань є серйозний бізнес. Запускання скрипта, який пишуть на 1му курсі, коштує від $13 до $159 на місяць. За $13 дозволяється це робити кожні 2 тижні, а за $159 с барскоґо плєча до 1 разу на день.

Для тих хто готовий нарешті "стати професіоналом", контора зовсім іншого гатунку пропонує вдосконалені та ентерпрайзні рішення за $449 та $1249 на місяць відповідно. Тоді відкриваються потужні можливості відправляння csv-звіту до "колеги чи фрілансера".

Є статичний уйоб-сайта з 375ма сторінками, який має 241 ікстернальний лінка. Зазвичай подібні сайти перевіряють локально на рівні .md хфайлів, з яких він генерується, і ютіліти, які це роблять, працюють доволі швидко, т.я. їм не треба повзати по http зі сторінки до сторінки, створюючи графа. Про них ми говорити не будемо.

Щоб перевірити задіплойеного сайта, а не директорію з .md-хфайлами, додається прублема рекурсивного знаходження посилань та питання коли зупинятися. Спочатку, я вирішив пошукати щось готове ув пекеджах Федори: якийсь пáйфонівський скрипта linkchecker, який за майже 4 хв на локалхості і рівнем рекурсії 20, знайшов 60 лінків загалом, замість 241.

Потім я подивився на W3C'шний link-checker (категорія програм, що є сповнена різноманітністю імен). Окрім уйоб інтерхфейсу він має CLI компонента--перл-скрипта, який витратив 13 хв для народження лоґу з 27859 рядками, де посеред машино-нечитабельного мотлоху пропонується шукати зламані посилання. Можливо, як за таке чарджити клаентів по $1249, то це має сенс; найняти когось буде нескладно.

Ґітхаб є повний "fast" та "async" чекерів, найпопулярнійший з яких (1.9K зірочок) не підтримує рекурсії, але друкує затишні 🔍, ✅, 🚫, 💤 емоджі ув консолі та рожеву progress bar. Рисою професіоналізму посеред сучасних лікн-чекерів є порада використовувати їх з докер-контейнеру, тому що для відправляння http ріквестів наш час вимагає >= 216 бібліотек на будь-якій мові пограмування.

Чи складно написати свій, наприклад, на bash? Хоча він буде мати міцний вайб нульових, коли завантажена сторінка якимось wget майже відповідала сторінці яку рендерив бовзер, для перевірки сторінок, наприклад, документації, такий чекер все одно буде залишатися корисним. Назвемо його badlinks:

$ wc -l lib.bash url* badlinks
  21 lib.bash
  28 urlcheck
  14 urlextract.rb
  54 urlextract-recursive
  24 badlinks
 141 total

На чистому bash це зробити майже неможливо: переписування nokogiri на шелі я залишаю комусь іншому, а ліпшого парсера кострубатого html, аніж nokogiri, не існує ув природі.

badlinks складається з 3 компонентів:

  1. парсера html, що друкує всі посилання з нього (не рекурсивно);
  2. рекурсивного crawler'а, який знаходить посилання з конь тент тайпом text/html, та занурюється до них; на кожному етапі він друкує знайдені leafs;
  3. скрипта, який читає список посилань від п.2 та перевіряє їх валідність.

Парсінґ html

$ curl -sL google.com | ./urlextract.rb q:
https://www.google.com/imghp?hl=uk&tab=wi
http://maps.google.com.ua/maps?hl=uk&tab=wl
…
q:/intl/uk/about.html

q: тут означає будь-яку base, відносно якої друкуються релатівні посилання.

$ cat urlextract.rb
#!/usr/bin/env -S ruby -rnokogiri -raddressable/uri

include Addressable

def eh = abort "Usage: #{$0} base_url < html"
base = URI.parse $*[0] rescue eh
eh unless base&.scheme

Nokogiri::HTML(STDIN.read).css('links,a,img,iframe,script,video,audio').each {|n|
  u = URI.parse(n['src'] || n['href']) rescue next
  next if !u || (!u.scheme && u.path.strip == '')
  u.fragment = nil
  puts u.scheme ? u : base.join(u)
}

Щоб воно працювало, треба сказати gem install nokogiri addressable. Вбудований ув stdlib парсер url є не такий гнучкий як addressable.

Фрагмент (частина рядка після #) видаляється для простоти, хоча, звичайно, можна було би намагатися шукати його (фрагмент) потім ув html.

Філософське питання, яке може виникнути: чи має ютіліта друкувати унікальні лінки лише (тому що уйоб-сторінка може містити кілька геть однакових), чи залишати процес фільтрування downstream, так би мовити, процесам.

Crawler

… є найбільш неприємним компонентом. Шелóві мовні конструкції допомагають погано, і найпримітивніший пошук ув глибину з пародією на детектора циклів, це є все що вдалося втиснути ув 54 рядка коду.

./urlextract-recursive http://127.0.0.1/
0 Scanning http://127.0.0.1/ .
1 Scanning http://127.0.0.1/foo.html http://127.0.0.1/
2 Scanning http://127.0.0.1/bar.png http://127.0.0.1/foo.html
2 External http://example.com http://127.0.0.1/foo.html
1 Leaf http://127.0.0.1/baz.jpg http://127.0.0.1/
…

Алгоритма є вкрай простий:

  1. З отриманого стартового url автоматично дізнаємося про origin. Це можна зробити башом як це було продемонстровано ув пості HTTP get на баші за допомогою страхітливого regexp'у. Origin потрібен для розрізняння посилань на наш уйоб-сайт від посилань на ікстернальні ресурси.
  1. Для кожного лінка зі стартового url робимо HTTP HEAD ріквест. Якщо останній є text/html, занурюємося в, якщо ні--друкуємо його як leaf.

    Формат друку:

     level type url parent

    Щоб не сканувати лінка декілька разів, записуємо його ув хфайл history (1 лінк на рядок для макс швидкості grep'у). Рівень занурення регулюється користувачем і по замовчуванню == 20.

    parent потрібен лише для ютіліти ув наступному розділі.

$ cat urlextract-recursive
#!/usr/bin/env bash

set -e -o pipefail
__dir__=$(dirname "$(readlink -f "$0")")
. "$__dir__/lib.bash"

jn() { jobs -l | wc -l; }

scan() {
    local url="$1" level="$2" parent="$3"

    [ "$level" -ge "$LEVEL" ] && return 0
    echo "$level Scanning $url $parent"

    level=$((level+1))
    for u in `fetch "$url" | "$__dir__/urlextract.rb" "$url" | sort -u`; do
        search "$u" "$history" && continue
        echo "$u" >> "$history"

        if echo "$u" | grep -F -- "${URL[host]}" >/dev/null 2>&1; then
            if is_html "$u"; then
                local async=
                [ 1 == $level ] && [ "$JOBS" -gt 1 ] \
                    && [ `jn` -lt "$JOBS" ] && async=1
                if [ $async ] ; then
                    scan "$u" $level "$url" &
                else
                    scan "$u" $level "$url"
                fi
            else
                echo "$level Leaf $u $url"
            fi
        else
            echo "$level External $u" "$url"
        fi
    done
}

type curl > /dev/null
: "${LEVEL:=20}"
: "${JOBS:=`nproc`}"
url=${1:?Usage [LEVEL=$LEVEL] $0 url}

url_parse "$url"
history=${HISTORY:-`mktemp /tmp/urlextract-recursive.XXXXXX`}
trap 'rm -f $history; exit 130' 1 2 15
trap 'rm -f $history' 0

if is_html "$url"; then
    scan "$url" 0 .
    wait
else
    echo "0 Leaf $url ."
fi

(Вибачте за простирадло.)

Скрипта намагається робити конкурентні ріквести. Найнеприємніше тут є неможливість знати заздалегідь скільки посилань буде далі і складність створення фіксованої кількості worker'ів, перевірка яких була би можлива ув child процесах. Тому "паралелізм" використовується лише на рівні 1. Це означає якщо стартовий url мав рівно 1 text/html посилання, поралелізму не буде.

Якщо не робити ліміта на конкурентні ріквести, вбити мошину можна дуже легко. Я так тестував безлімітний варіянт з 6K gnu libstdc++ doxygen сторінками на локалхості: стався еквівалента форк бімби, лайнакс грохнув ікси та почав люто плюватися сторінками вбитих процесів.

Енівей, отримані рядки від urlextract-recursive легко фільтруються. Наприклад, щоб дізнатися який був макс рівень рекурсії і чи не треба його збільшити:

$ ./urllextract-recursive http://127.0.0.1/ | tee 1
$ awk '{print $1}' 1 | sort -un | tail -1
11

або показати лише ікстернальні посилання:

$ awk '$2 == "External"' 1

Нецікаві шматки з lib.bash:

fetch() { curl -sfL --connect-timeout 3 -m 3 -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36' -H 'accept-language: en-US,en;q=0.9' -H 'upgrade-insecure-requests: 1' "$@"; }

is_html() { fetch -I "$1" | grep -Ei '^content-type:\s+text/html' > /dev/null; }

search() { grep -Fx -- "$1" "$2" > /dev/null; }

Перевірка посилань

На відміну від попередньої ютіліти, ця чудово і елементарно паралелізується. Сам скрипта про це гадки не має, тому що оперує лише з 1 url, але конкурентна перевірка робиться ув фінальному врапері наступного розділу через xargs.

$ cat urlcheck
#!/usr/bin/env bash

set -e -o pipefail
__dir__=$(dirname "$(readlink -f "$0")")
. "$__dir__/lib.bash"

check_link() {
    local ec=0
    fetch "$1" | head -c1 > /dev/null || {
        ec=$?
        [ $ec == 23 ] && return 0 # pipe was closed by 'head'
        [ $ec -lt 100 ] && echo "Bad $1 in $2"
        return 101
    }
}

type curl > /dev/null
url=${1:?Usage: IGNORE=domains.txt $0 url parent}
parent=${2:-.}

url_parse "$url" || { echo "Invalid $url in $parent"; exit 100; }

if [ "$IGNORE" ] && search "${URL[host]}" "$IGNORE" ; then
    echo "Ignoring $url in $parent"
else
    [ 2 == $? ] && exit 100
    check_link "$url" "$parent"
fi

Єдина оптимізація гідна уваги це є check_link(), який читає лише 1 байт успішного ріспонзу і перевіряє статусного кода curl.

Деякі уйоб-сайти ітервебу (наприклад wsj, або припизджена квора) вважають curl ботом, ув незалежності від user agent, тому якщо вказати ув IGNORE env var шлях до хфайлу зі списком доменів, посилання з ними перевірятися не будуть.

Врапер

Фінальна обгортка:

$ cat badlinks
#!/usr/bin/env bash

set -e -o pipefail
__dir__=$(dirname "$(readlink -f "$0")")

usage="Usage: $0 [-l max level] [-j max jobs] [-e] [-i domains.txt] url"
link_type=Leaf
while getopts i:l:j:e opt; do
    case $opt in
        i) opt_i=$OPTARG ;;
        l) opt_l=$OPTARG ;;
        j) opt_j=$OPTARG ;;
        e) link_type="$link_type|External" ;;
        *) echo "$usage" 1>&2; exit 1
    esac
done
shift $((OPTIND-1))

: "${1:?$usage}"
export LEVEL=${opt_l:-20} JOBS=${opt_j:-`nproc`} IGNORE=${opt_i}

"$__dir__/urlextract-recursive" "$1" | \
    awk '$2 ~ /'"$link_type"'/ {print $3, $4}' | \
    xargs -r -n2 -P "$JOBS" "$__dir__/urlcheck"

Зі статичним уйоб-сайтом, на який попередньо знайдені готові пекеджи витрачали від 4 до 13 хв, набір скриптів шелóвих впорався за 16 секунд, відсканувавши 769 лінка.

juan_gandhi: (Default)

[personal profile] juan_gandhi 2024-08-03 11:49 am (UTC)(link)

It's rather precious. Thank you!

straktor: benders (Default)

[personal profile] straktor 2024-08-03 01:59 pm (UTC)(link)
для обычного, нормального, сайта всё это хорошо

еслі сайт рендерітся прілагой по бд, напрімер каталог магазіна, начінаются вопросы, тіпа а дерево лі каталог ілі цікліческій граф

еслі ссылкі генерятся жабаскріптом на лету, второй подвыверт -- без выполненія жс в хедлесс дом контейнере с полнымі браузер апі будет куча непрочёсанного

третій подвыверт -- еслі прілага імеет концепт ролей юзера, уровні кастомера, аксес для разных уровней

почему я это всё пішу... із опыта
лінкі реально генерілісь на a href="javascript:clickOffer(20567)", где был і свой счётчік кліков, і бредкрамб, і рекомендер

реально нікто логі на предмет 404 не смотрел, разумеется
Edited 2024-08-03 13:59 (UTC)