grab-gromit.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. #!/usr/bin/env bash
  2. #
  3. # grab-gromit.sh -- mirror AOL "gromit" web apps (AIM Express, buddy-icon
  4. # uploader, ...) out of the Wayback Machine.
  5. #
  6. # Everything lived under http://o.aolcdn.com/aim/gromit/<app>/<build>/, so one
  7. # CDX prefix query per build enumerates the whole app. For each distinct URL we
  8. # pick the capture closest to a target date, so you get a self-consistent
  9. # snapshot rather than a mix of crawls years apart.
  10. #
  11. # Downloads are strictly SERIAL with a delay between every request. The Wayback
  12. # Machine starts refusing connections outright (curl exit 7, not an HTTP 429)
  13. # if you fan out, and it stays angry for a while -- so slow beats parallel.
  14. #
  15. # Needs only bash, curl, awk, shasum, od. No jq: we ask CDX for text output.
  16. #
  17. # Usage:
  18. # ./grab-gromit.sh # fetch every target below
  19. # ./grab-gromit.sh express # just the named target(s)
  20. # ./grab-gromit.sh --list # enumerate, download nothing
  21. # ./grab-gromit.sh --delay 5 # be extra polite
  22. # ./grab-gromit.sh -o clients express # lands in clients/express/
  23. # ./grab-gromit.sh --url http://o.aolcdn.com/aim/gromit/x/y.swf --near 20130905
  24. set -uo pipefail
  25. CDX="https://web.archive.org/cdx/search/cdx"
  26. WEB="https://web.archive.org/web"
  27. UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
  28. # --- what to mirror -------------------------------------------------------
  29. # One entry per line: <name> <prefix> <near>
  30. # near is YYYYMMDD, or "auto" to let the script choose (see auto.awk below).
  31. TARGETS='
  32. express o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/ auto
  33. express-html o.aolcdn.com/aim/gromit/aim_express/gm/081202.1/ auto
  34. iconuploader o.aolcdn.com/aim/gromit/iconuploader/110128.1.5797/ auto
  35. lifestream o.aolcdn.com/lifestream/img/ auto
  36. '
  37. OUTDIR="archive"
  38. DELAY=2.5
  39. MAX_RETRIES=5
  40. WINDOW_DAYS=180
  41. LIST_ONLY=0
  42. FORCE=0
  43. NEAR_OVERRIDE="20130905"
  44. MANIFEST_PATH=""
  45. EXTRA_URLS=""
  46. WANTED=""
  47. REQUESTS=0
  48. N_OK=0; N_SKIP=0; N_FAIL=0; N_SUSPECT=0
  49. die() { echo "$*" >&2; exit 1; }
  50. usage() { sed -n '3,22p' "$0" | sed 's/^#\{1,\} \{0,1\}//'; exit 0; }
  51. while [ $# -gt 0 ]; do
  52. case "$1" in
  53. -o|--outdir) OUTDIR=$2; shift 2 ;;
  54. -d|--delay) DELAY=$2; shift 2 ;;
  55. -r|--max-retries) MAX_RETRIES=$2; shift 2 ;;
  56. -w|--window-days) WINDOW_DAYS=$2; shift 2 ;;
  57. --near) NEAR_OVERRIDE=$2; shift 2 ;;
  58. --manifest) MANIFEST_PATH=$2; shift 2 ;;
  59. --url) EXTRA_URLS="$EXTRA_URLS $2"; shift 2 ;;
  60. --list) LIST_ONLY=1; shift ;;
  61. --force) FORCE=1; shift ;;
  62. -h|--help) usage ;;
  63. -*) die "unknown option: $1" ;;
  64. *) WANTED="$WANTED $1"; shift ;;
  65. esac
  66. done
  67. TMP=$(mktemp -d "${TMPDIR:-/tmp}/gromit.XXXXXX") || die "mktemp failed"
  68. trap 'rm -rf "$TMP"' EXIT
  69. trap 'echo; echo "interrupted -- re-run to resume (existing files are skipped)"; exit 130' INT
  70. # --- awk programs ---------------------------------------------------------
  71. # In files rather than inline strings: awk's $1 and the shell's $1 don't mix.
  72. cat > "$TMP/lib.awk" <<'AWKEOF'
  73. # Days since the epoch from a YYYYMMDD... stamp (Howard Hinnant days_from_civil).
  74. # Real day counts, so a capture on Dec 31 and one on Jan 1 come out 1 day apart
  75. # rather than the ~8770 that naive subtraction of the stamps would give.
  76. function d2n(s, y,m,d,era,yoe,doy,doe) {
  77. y = substr(s,1,4)+0; m = substr(s,5,2)+0; d = substr(s,7,2)+0
  78. if (m < 1) m = 1
  79. if (d < 1) d = 1
  80. y -= (m <= 2)
  81. era = int((y >= 0 ? y : y-399) / 400)
  82. yoe = y - era*400
  83. doy = int((153*(m + (m > 2 ? -3 : 9)) + 2)/5) + d - 1
  84. doe = yoe*365 + int(yoe/4) - int(yoe/100) + doy
  85. return era*146097 + doe - 719468
  86. }
  87. # The archive keys o.aolcdn.com and o.aolcdn.com:80 as separate urlkeys, so
  88. # without this every page shows up twice and each copy picks its own capture.
  89. function norm(u, rest,i,host,path) {
  90. rest = u
  91. sub(/^[a-zA-Z]+:\/\//, "", rest)
  92. i = index(rest, "/")
  93. if (i) { host = substr(rest,1,i-1); path = substr(rest,i) }
  94. else { host = rest; path = "/" }
  95. sub(/:(80|443)$/, "", host)
  96. return tolower(host) path
  97. }
  98. AWKEOF
  99. # Choose the capture date that pulls the tightest cluster out of the CDX rows.
  100. # Every date present is tried as a candidate; the winner minimizes the total
  101. # distance from each URL to its own nearest capture. This beats guessing,
  102. # because a build's assets usually got swept up in one or two big crawls and we
  103. # want to land on the biggest one.
  104. cat > "$TMP/auto.awk" <<'AWKEOF'
  105. {
  106. u = norm($1)
  107. if (!(u in cnt)) urls[++nu] = u
  108. day[u SUBSEP ++cnt[u]] = d2n($2)
  109. stamps[++ns] = $2
  110. }
  111. END {
  112. bestcost = -1
  113. for (i = 1; i <= ns; i++) {
  114. c = d2n(stamps[i]); cost = 0
  115. for (j = 1; j <= nu; j++) {
  116. u = urls[j]; m = -1
  117. for (x = 1; x <= cnt[u]; x++) {
  118. dd = day[u SUBSEP x] - c
  119. if (dd < 0) dd = -dd
  120. if (m < 0 || dd < m) m = dd
  121. }
  122. cost += m
  123. }
  124. if (bestcost < 0 || cost < bestcost) { bestcost = cost; best = stamps[i] }
  125. }
  126. print best
  127. }
  128. AWKEOF
  129. # Collapse captures to one per normalized URL: the one nearest `near`.
  130. cat > "$TMP/pick.awk" <<'AWKEOF'
  131. BEGIN { target = d2n(near) }
  132. {
  133. u = norm($1)
  134. off = d2n($2) - target
  135. if (off < 0) off = -off
  136. if (!(u in orig) || off < bestoff[u]) {
  137. bestoff[u] = off; orig[u] = $1; ts[u] = $2; mime[u] = $4
  138. }
  139. }
  140. END { for (u in orig) print orig[u], ts[u], mime[u], bestoff[u] }
  141. AWKEOF
  142. # --- fetching -------------------------------------------------------------
  143. # fetch OUTFILE LABEL -- curl args come from the CURL_ARGS array, so callers can
  144. # use -G/--data-urlencode for CDX and a plain URL for downloads.
  145. fetch() {
  146. local out=$1 label=$2
  147. local attempt=0 code rc wait reason
  148. while :; do
  149. sleep "$DELAY"
  150. code=$(curl -sS -A "$UA" --max-time 120 -D "$TMP/hdr" \
  151. -o "$out.part" -w '%{http_code}' "${CURL_ARGS[@]}" 2>"$TMP/err")
  152. rc=$?
  153. REQUESTS=$((REQUESTS+1))
  154. if [ "$rc" -eq 0 ] && [ "$code" = "200" ]; then
  155. mv "$out.part" "$out"
  156. return 0
  157. fi
  158. rm -f "$out.part"
  159. wait=""
  160. if [ "$rc" -ne 0 ]; then
  161. reason="curl exit $rc"
  162. # 7 = couldn't connect: we tripped the throttle. Back off hard and
  163. # keep the floor raised for the rest of the run.
  164. case "$rc" in
  165. 7|28|56) bump_delay "connection refused/timed out" ;;
  166. esac
  167. else
  168. reason="HTTP $code"
  169. if [ "$code" = "404" ]; then
  170. echo " !! 404 $label"
  171. return 1
  172. fi
  173. if [ "$code" = "429" ]; then
  174. bump_delay "rate limited"
  175. wait=$(awk 'tolower($1) ~ /^retry-after:/ {gsub(/\r/,""); print $2; exit}' "$TMP/hdr")
  176. case "$wait" in ''|*[!0-9]*) wait="" ;; esac
  177. fi
  178. fi
  179. if [ "$attempt" -ge "$MAX_RETRIES" ]; then
  180. echo " !! $label: giving up after $((attempt+1)) attempts ($reason)"
  181. return 1
  182. fi
  183. [ -n "$wait" ] || wait=$(awk -v d="$DELAY" -v a="$attempt" \
  184. 'BEGIN { v = d*(2^a)+2; print int(v > 120 ? 120 : v) }')
  185. attempt=$((attempt+1))
  186. echo " $reason -- retry $attempt/$MAX_RETRIES in ${wait}s"
  187. sleep "$wait"
  188. done
  189. }
  190. bump_delay() {
  191. DELAY=$(awk -v d="$DELAY" 'BEGIN { v = d*1.5+1; printf "%.1f", (v > 30 ? 30 : v) }')
  192. echo " $1 -- floor delay now ${DELAY}s"
  193. }
  194. # cdx_list PREFIX -> "original timestamp statuscode mimetype" rows on stdout
  195. cdx_list() {
  196. CURL_ARGS=(-G "$CDX"
  197. --data-urlencode "url=$1"
  198. --data-urlencode "matchType=prefix"
  199. --data-urlencode "output=text"
  200. --data-urlencode "fl=original,timestamp,statuscode,mimetype"
  201. --data-urlencode "filter=statuscode:200"
  202. --data-urlencode "limit=5000")
  203. fetch "$TMP/cdx.txt" "cdx $1" || return 1
  204. awk 'NF >= 2' "$TMP/cdx.txt"
  205. }
  206. # --- local paths ----------------------------------------------------------
  207. # Map an archived URL to a path under the target dir, preserving the tree. The
  208. # build directory becomes the root, so .../gm/<build>/loadable/x.png lands at
  209. # <outdir>/<name>/loadable/x.png. Query strings drop out of the filename but get
  210. # hashed back in, because Main.html?env=prod and ?env=dev are different files.
  211. local_path() {
  212. local original=$1 prefix=$2
  213. local rest path query stem ext tag root
  214. rest=${original#*://}
  215. case "$rest" in
  216. */*) path=/${rest#*/} ;;
  217. *) path=/ ;;
  218. esac
  219. query=""
  220. case "$path" in *\?*) query=${path#*\?}; path=${path%%\?*} ;; esac
  221. root=/${prefix#*/}
  222. case "$path" in "$root"*) path=${path#"$root"} ;; esac
  223. path=${path#/}
  224. [ -n "$path" ] || path="index.html"
  225. case "$path" in */) path="${path}index.html" ;; esac
  226. if [ -n "$query" ]; then
  227. case "$path" in
  228. *.*) stem=${path%.*}; ext=.${path##*.} ;;
  229. *) stem=$path; ext=.html ;;
  230. esac
  231. tag=$(printf '%s' "$query" | shasum | cut -c1-6)
  232. path="$stem.$tag$ext"
  233. fi
  234. printf '%s' "$path"
  235. }
  236. # True when we got the real bytes, false when the archive handed back its HTML
  237. # wrapper -- the exact trap that yields an HTML file named OnlinePanel.swf.
  238. magic_ok() {
  239. local f=$1 head4
  240. head4=$(od -A n -t x1 -N 4 "$f" 2>/dev/null | tr -d ' \n')
  241. case "$(printf '%s' "${f##*.}" | tr 'A-Z' 'a-z')" in
  242. swf) case "$head4" in 465753*|435753*|5a5753*) return 0 ;; *) return 1 ;; esac ;;
  243. png) case "$head4" in 89504e47*) return 0 ;; *) return 1 ;; esac ;;
  244. gif) case "$head4" in 474946*) return 0 ;; *) return 1 ;; esac ;;
  245. jpg|jpeg) case "$head4" in ffd8ff*) return 0 ;; *) return 1 ;; esac ;;
  246. *) return 0 ;;
  247. esac
  248. }
  249. # --- assemble the target list ---------------------------------------------
  250. targets=$(printf '%s\n' "$TARGETS" | awk 'NF')
  251. if [ -n "$(printf '%s' "$WANTED" | tr -d ' ')" ]; then
  252. sel=""
  253. for w in $WANTED; do
  254. line=$(printf '%s\n' "$targets" | awk -v n="$w" '$1 == n')
  255. [ -n "$line" ] || die "unknown target: $w (have: $(printf '%s\n' "$targets" | awk '{printf "%s ", $1}'))"
  256. sel="$sel$line
  257. "
  258. done
  259. targets=$sel
  260. fi
  261. if [ -n "$(printf '%s' "$EXTRA_URLS" | tr -d ' ')" ]; then
  262. [ -n "$(printf '%s' "$WANTED" | tr -d ' ')" ] || targets=""
  263. for u in $EXTRA_URLS; do
  264. targets="$targets
  265. adhoc ${u%%\?*} $NEAR_OVERRIDE"
  266. done
  267. fi
  268. # --- main loop ------------------------------------------------------------
  269. # No pipe into the loop: a pipeline would run it in a subshell and the tallies
  270. # and the raised DELAY floor would be discarded at the end of each target.
  271. printf '%s\n' "$targets" | awk 'NF' > "$TMP/targets.txt"
  272. : > "$TMP/manifest.json"
  273. first=1
  274. while read -r name prefix near; do
  275. printf '\n=== %s ===\n' "$name"
  276. printf ' prefix %s\n near %s\n' "$prefix" "$near"
  277. if ! cdx_list "$prefix" > "$TMP/rows.txt"; then
  278. echo " !! CDX failed"
  279. N_FAIL=$((N_FAIL+1))
  280. continue
  281. fi
  282. if [ ! -s "$TMP/rows.txt" ]; then
  283. echo " !! no captures found"
  284. continue
  285. fi
  286. if [ "$near" = "auto" ]; then
  287. near=$(awk -f "$TMP/lib.awk" -f "$TMP/auto.awk" "$TMP/rows.txt")
  288. echo " auto-selected capture date $near"
  289. fi
  290. awk -v near="$near" -f "$TMP/lib.awk" -f "$TMP/pick.awk" "$TMP/rows.txt" \
  291. | sort > "$TMP/picks.txt"
  292. ncap=$(wc -l < "$TMP/rows.txt" | tr -d ' ')
  293. npick=$(wc -l < "$TMP/picks.txt" | tr -d ' ')
  294. echo " $ncap captures -> $npick unique URLs"
  295. echo " crawl dates: $(awk '{print substr($2,1,8)}' "$TMP/picks.txt" \
  296. | sort | uniq -c | sort -rn | awk '{printf "%s x%s, ", $2, $1}' | sed 's/, $//')"
  297. if [ "$LIST_ONLY" -eq 1 ]; then
  298. awk '{printf " %s %s (%sd) %s\n", $2, $3, $4, $1}' "$TMP/picks.txt"
  299. continue
  300. fi
  301. i=0
  302. while read -r original ts mime off; do
  303. i=$((i+1))
  304. rel="$name/$(local_path "$original" "$prefix")"
  305. dest="$OUTDIR/$rel"
  306. if [ "$FORCE" -eq 0 ] && [ -s "$dest" ]; then
  307. printf '[%3d/%s] skip %s\n' "$i" "$npick" "$rel"
  308. N_SKIP=$((N_SKIP+1))
  309. continue
  310. fi
  311. note=""
  312. [ "$off" -gt "$WINDOW_DAYS" ] && note=" [${off}d off]"
  313. printf '[%3d/%s] get %s%s\n' "$i" "$npick" "$rel" "$note"
  314. mkdir -p "$(dirname "$dest")"
  315. # id_ is what makes the archive return the ORIGINAL bytes instead of its
  316. # rewritten viewer page. Omit it and every .swf comes back as HTML.
  317. CURL_ARGS=("$WEB/${ts}id_/$original")
  318. if ! fetch "$dest" "$original"; then
  319. N_FAIL=$((N_FAIL+1))
  320. continue
  321. fi
  322. status=ok
  323. if magic_ok "$dest"; then
  324. N_OK=$((N_OK+1))
  325. else
  326. echo " !! wrong magic bytes -- archive returned a wrapper?"
  327. status=suspect
  328. N_SUSPECT=$((N_SUSPECT+1))
  329. fi
  330. bytes=$(wc -c < "$dest" | tr -d ' ')
  331. echo " $bytes bytes $ts"
  332. [ "$first" -eq 1 ] || printf ',\n' >> "$TMP/manifest.json"
  333. first=0
  334. printf ' {"url": "%s", "timestamp": "%s", "mime": "%s", "path": "%s", "bytes": %s, "days_off": %s, "status": "%s"}' \
  335. "$original" "$ts" "$mime" "$rel" "$bytes" "$off" "$status" >> "$TMP/manifest.json"
  336. done < "$TMP/picks.txt"
  337. done < "$TMP/targets.txt"
  338. if [ "$LIST_ONLY" -eq 0 ]; then
  339. [ -n "$MANIFEST_PATH" ] || MANIFEST_PATH="$OUTDIR/manifest.json"
  340. mkdir -p "$(dirname "$MANIFEST_PATH")"
  341. { echo "["; cat "$TMP/manifest.json"; echo; echo "]"; } > "$MANIFEST_PATH"
  342. printf '\n%s ok, %s skipped, %s suspect, %s failed (%s requests)\n' \
  343. "$N_OK" "$N_SKIP" "$N_SUSPECT" "$N_FAIL" "$REQUESTS"
  344. echo "manifest: $MANIFEST_PATH"
  345. [ "$N_SUSPECT" -gt 0 ] && echo "re-run with --force to retry the suspect files"
  346. fi
  347. exit 0