/* See LICENSE file for copyright and license details. * * sbot - Simple Archiver Bot * * Creates self-contained archives of websites with all * resources inlined as data URIs. */ #include #include #include #include #include #include #include #include "config.h" #include "crawl.h" #include "detect.h" #include "fetch.h" #include "parse.h" #include "robots.h" #include "util.h" /* Global options */ static int verbose = 0; static int recursive = 0; static int max_depth = MAX_DEPTH; static int respect_robots = 1; static const char *author = "Unknown"; static const char *output_dir = NULL; static char *base_domain = NULL; static void usage(void) { fprintf(stderr, "usage: sbot [-vrR] [-d depth] [-o dir]" " [-a author] url\n" "\n" " -v verbose output\n" " -r recursive (crawl entire site)\n" " -R ignore robots.txt\n" " -d depth max crawl depth (default: %d)\n" " -o dir output directory\n" " -a author site author name\n", MAX_DEPTH); exit(1); } static int mkdirp(const char *path) { char *p, *sep; p = xstrdup(path); for (sep = p + 1; *sep; sep++) { if (*sep == '/') { *sep = '\0'; if (mkdir(p, 0755) != 0 && errno != EEXIST) { free(p); return -1; } *sep = '/'; } } free(p); return 0; } static char * fetch_and_encode(const char *url, const char *base) { char *resolved, *mime, *b64, *data_uri, *semi; Response *resp; size_t b64_len, uri_len; resolved = url_resolve(base, url); if (verbose) fprintf(stderr, " resource: %s\n", resolved); resp = fetch_url(resolved); if (!resp || resp->status_code >= 400 || resp->size == 0) { free(resolved); response_free(resp); return NULL; } if (resp->content_type) { mime = xstrdup(resp->content_type); semi = strchr(mime, ';'); if (semi) *semi = '\0'; str_trim(mime); } else { mime = get_mime_type(resolved); } b64 = base64_encode((unsigned char *)resp->data, resp->size, &b64_len); uri_len = 5 + strlen(mime) + 8 + b64_len + 1; data_uri = xmalloc(uri_len); snprintf(data_uri, uri_len, "data:%s;base64,%s", mime, b64); free(mime); free(b64); free(resolved); response_free(resp); return data_uri; } static char * generate_header(const char *title, const char *source_url) { char *date, *domain, *header; size_t len; date = get_iso_date(); domain = url_get_domain(source_url); len = 2048 + strlen(title) + strlen(source_url) + strlen(author) + strlen(domain); header = xmalloc(len); snprintf(header, len, "\n", title, source_url, domain, author, ARCHIVER_NAME, ARCHIVER_SITE, date, ARCHIVER_VERSION); free(date); free(domain); return header; } static int path_depth(const char *path) { int depth = 0; const char *p; for (p = path; *p; p++) if (*p == '/') depth++; return depth; } static char * make_relative_prefix(int depth) { char *prefix; size_t len; int i; if (depth == 0) return xstrdup(""); len = depth * 3 + 1; prefix = xmalloc(len); prefix[0] = '\0'; for (i = 0; i < depth; i++) strcat(prefix, "../"); return prefix; } static char * rewrite_links(char *html, const char *rel_path) { int depth; char *prefix, *result, *new_result; size_t prefix_len, search_offset, pos, old_len, new_len; const char *p; depth = path_depth(rel_path); prefix = make_relative_prefix(depth); prefix_len = strlen(prefix); result = html; search_offset = 0; while (1) { p = strstr(result + search_offset, "href=\"/"); if (!p) break; if (p[7] == '/') { search_offset = (p - result) + 8; continue; } pos = p - result + 6; old_len = strlen(result); new_len = old_len - 1 + prefix_len; new_result = xmalloc(new_len + 1); memcpy(new_result, result, pos); memcpy(new_result + pos, prefix, prefix_len); memcpy(new_result + pos + prefix_len, result + pos + 1, old_len - pos); free(result); result = new_result; search_offset = pos + prefix_len; } search_offset = 0; while (1) { p = strstr(result + search_offset, "src=\"/"); if (!p) break; if (p[6] == '/') { search_offset = (p - result) + 7; continue; } pos = p - result + 5; old_len = strlen(result); new_len = old_len - 1 + prefix_len; new_result = xmalloc(new_len + 1); memcpy(new_result, result, pos); memcpy(new_result + pos, prefix, prefix_len); memcpy(new_result + pos + prefix_len, result + pos + 1, old_len - pos); free(result); result = new_result; search_offset = pos + prefix_len; } free(prefix); return result; } static char * inline_css(char *html, const char *base) { char *result, *tag, *href, *resolved; char *new_tag, *new_result; const char *link_start, *link_end; const char *href_start, *href_end; size_t search_offset, tag_offset, tag_len, href_len; size_t new_tag_len, old_len, new_len, result_len; Response *resp; char quote; result = html; search_offset = 0; while (1) { link_start = strcasestr(result + search_offset, "'); if (!link_end) break; tag_offset = link_start - result; tag_len = link_end - link_start; tag = xmalloc(tag_len + 1); memcpy(tag, link_start, tag_len); tag[tag_len] = '\0'; if (!strcasestr(tag, "stylesheet")) { free(tag); search_offset = (link_end - result) + 1; continue; } href_start = strcasestr(tag, "href="); if (!href_start) { free(tag); search_offset = (link_end - result) + 1; continue; } href_start += 5; quote = 0; if (*href_start == '"' || *href_start == '\'') quote = *href_start++; href_end = href_start; if (quote) { while (*href_end && *href_end != quote) href_end++; } else { while (*href_end && *href_end != ' ' && *href_end != '>') href_end++; } href_len = href_end - href_start; href = xmalloc(href_len + 1); memcpy(href, href_start, href_len); href[href_len] = '\0'; resolved = url_resolve(base, href); if (verbose) fprintf(stderr, " css: %s\n", resolved); resp = fetch_url(resolved); free(resolved); if (resp && resp->status_code < 400 && resp->size > 0) { new_tag_len = 7 + resp->size + 8 + 1; new_tag = xmalloc(new_tag_len); snprintf(new_tag, new_tag_len, "", resp->data); old_len = (link_end + 1) - link_start; new_len = strlen(new_tag); result_len = strlen(result); new_result = xmalloc( result_len - old_len + new_len + 1); memcpy(new_result, result, tag_offset); memcpy(new_result + tag_offset, new_tag, new_len); memcpy(new_result + tag_offset + new_len, link_end + 1, result_len - tag_offset - old_len + 1); free(result); result = new_result; search_offset = tag_offset + new_len; free(new_tag); } else { search_offset = (link_end - result) + 1; } response_free(resp); free(href); free(tag); } return result; } static void extract_links(const char *html, const char *base_url, UrlQueue *queue, VisitedSet *visited, int current_depth, Robots *robots) { ResourceList *resources; Resource *r; char *norm, *path; const char *pstart; if (current_depth >= max_depth) return; resources = parse_html(html, base_url); for (r = resources->head; r; r = r->next) { if (r->type != RES_PAGE) continue; if (str_starts_with(r->url, "mailto:") || str_starts_with(r->url, "tel:") || str_starts_with(r->url, "javascript:") || str_starts_with(r->url, "#")) continue; if (!url_same_domain(r->url, base_url)) continue; /* Check robots.txt */ if (robots) { pstart = r->url; if (str_starts_with(pstart, "https://")) pstart += 8; else if (str_starts_with(pstart, "http://")) pstart += 7; while (*pstart && *pstart != '/') pstart++; path = xstrdup(pstart[0] ? pstart : "/"); if (!robots_allowed(robots, path)) { if (verbose) fprintf(stderr, " robots: blocked" " %s\n", r->url); free(path); continue; } free(path); } norm = url_normalize(r->url); if (!visited_contains(visited, norm)) { visited_add(visited, norm); queue_push(queue, r->url, current_depth + 1); if (verbose) fprintf(stderr, " queued: %s\n", r->url); } free(norm); } reslist_free(resources); } static int save_page(const char *url, const char *final_url, const char *data, const char *rel_path) { char *html, *title, *header; char *full_path, *dir, *last_slash; size_t full_path_len; FILE *fp; html = xstrdup(data); title = parse_title(html); if (verbose) fprintf(stderr, " title: %s\n", title); html = inline_css(html, final_url); html = inline_resources(html, final_url, fetch_and_encode); html = rewrite_links(html, rel_path); header = generate_header(title, url); full_path_len = strlen(output_dir) + 1 + strlen(rel_path) + 1; full_path = xmalloc(full_path_len); snprintf(full_path, full_path_len, "%s/%s", output_dir, rel_path); dir = xstrdup(full_path); last_slash = strrchr(dir, '/'); if (last_slash) { *last_slash = '\0'; mkdirp(dir); mkdir(dir, 0755); } free(dir); fp = fopen(full_path, "w"); if (!fp) { warn("cannot write: %s", full_path); free(full_path); free(header); free(html); free(title); return -1; } fputs(header, fp); fputs(html, fp); fclose(fp); fprintf(stderr, " saved: %s\n", full_path); free(full_path); free(header); free(html); free(title); return 0; } static int archive_page(const char *url) { Response *resp; const char *final_url; char *rel_path; int ret; if (verbose) fprintf(stderr, "[0] %s\n", url); resp = fetch_url(url); if (!resp) { warn("failed to fetch: %s", url); return -1; } if (resp->status_code >= 400) { warn("HTTP %ld: %s", resp->status_code, url); response_free(resp); return -1; } if (resp->content_type && !strstr(resp->content_type, "text/html")) { if (verbose) fprintf(stderr, " skip non-HTML: %s\n", resp->content_type); response_free(resp); return 0; } /* Detect CMS type for informational output */ { SiteInfo *sinfo; sinfo = detect_site(resp->data, url); if (sinfo->type != SITE_UNKNOWN) fprintf(stderr, " CMS: %s\n", sinfo->name); siteinfo_free(sinfo); } final_url = resp->final_url ? resp->final_url : url; rel_path = url_to_path(url, base_domain); ret = save_page(url, final_url, resp->data, rel_path); free(rel_path); response_free(resp); return ret; } static void crawl_site(const char *start_url) { UrlQueue *queue; VisitedSet *visited; Robots *robots = NULL; QueueNode *node; Response *resp; const char *final_url; char *norm, *url, *rel_path; int depth, pages_archived, rate_ms; time_t start_time, now; queue = queue_new(); visited = visited_new(); if (respect_robots) { fprintf(stderr, "Fetching robots.txt for %s...\n", base_domain); robots = robots_fetch(base_domain); if (robots->nrules > 0) fprintf(stderr, " %d rules loaded\n", robots->nrules); else fprintf(stderr, " no restrictions\n"); if (robots_delay(robots) > 0) { rate_ms = robots_delay(robots) * 1000; fprintf(stderr, " crawl-delay: %ds\n", robots_delay(robots)); } else { rate_ms = RATE_LIMIT_MS; } } else { rate_ms = RATE_LIMIT_MS; } norm = url_normalize(start_url); visited_add(visited, norm); free(norm); queue_push(queue, start_url, 0); /* Detect CMS type from the start page */ { Response *detect_resp; SiteInfo *sinfo; detect_resp = fetch_url(start_url); if (detect_resp && detect_resp->status_code < 400 && detect_resp->data) { sinfo = detect_site(detect_resp->data, start_url); if (sinfo->type != SITE_UNKNOWN) { char **seeds; int nseed, i; fprintf(stderr, "Detected CMS: %s\n", sinfo->name); if (sinfo->feed_url) fprintf(stderr, " feed: %s\n", sinfo->feed_url); if (sinfo->sitemap_url) fprintf(stderr, " sitemap: %s\n", sinfo->sitemap_url); /* Add CMS-specific seed URLs */ seeds = detect_seed_urls(sinfo, base_domain, &nseed); for (i = 0; i < nseed; i++) { norm = url_normalize( seeds[i]); if (!visited_contains( visited, norm)) { visited_add( visited, norm); queue_push(queue, seeds[i], 0); if (verbose) fprintf(stderr, " seed: %s\n", seeds[i]); } free(norm); free(seeds[i]); } free(seeds); } siteinfo_free(sinfo); } response_free(detect_resp); } pages_archived = 0; start_time = time(NULL); while (!queue_empty(queue)) { node = queue_pop(queue); url = node->url; depth = node->depth; resp = fetch_url(url); if (!resp || resp->status_code >= 400) { if (verbose) fprintf(stderr, "[%d] SKIP %s\n", depth, url); response_free(resp); free(url); free(node); continue; } if (resp->content_type && !strstr(resp->content_type, "text/html")) { response_free(resp); free(url); free(node); continue; } final_url = resp->final_url ? resp->final_url : url; fprintf(stderr, "[d=%d q=%zu v=%zu] %s\n", depth, queue_size(queue), visited_count(visited), url); extract_links(resp->data, final_url, queue, visited, depth, robots); rel_path = url_to_path(url, base_domain); save_page(url, final_url, resp->data, rel_path); pages_archived++; if (pages_archived % PROGRESS_INTERVAL == 0) { now = time(NULL); fprintf(stderr, "\n--- %d pages, %zu queued, " "%zu visited, %lds ---\n\n", pages_archived, queue_size(queue), visited_count(visited), (long)(now - start_time)); } free(rel_path); response_free(resp); free(url); free(node); usleep(rate_ms * 1000); } now = time(NULL); fprintf(stderr, "\nDone: %d pages to %s/ in %lds\n", pages_archived, output_dir, (long)(now - start_time)); robots_free(robots); queue_free(queue); visited_free(visited); } int main(int argc, char *argv[]) { const char *url; int opt; while ((opt = getopt(argc, argv, "vrRd:o:a:h")) != -1) { switch (opt) { case 'v': verbose = 1; break; case 'r': recursive = 1; break; case 'R': respect_robots = 0; break; case 'd': max_depth = atoi(optarg); if (max_depth < 1) max_depth = 1; break; case 'o': output_dir = optarg; break; case 'a': author = optarg; break; case 'h': /* fallthrough */ default: usage(); } } if (optind >= argc) usage(); url = argv[optind]; base_domain = url_get_domain(url); if (!output_dir) output_dir = base_domain; if (mkdir(output_dir, 0755) != 0 && errno != EEXIST) die("cannot create directory: %s", output_dir); fetch_init(); fprintf(stderr, "sbot %s\n", ARCHIVER_VERSION); fprintf(stderr, "Target: %s\n", url); fprintf(stderr, "Output: %s/\n", output_dir); if (recursive) fprintf(stderr, "Mode: recursive (depth %d)\n", max_depth); if (!respect_robots) fprintf(stderr, "Warning: ignoring robots.txt\n"); fprintf(stderr, "\n"); if (recursive) crawl_site(url); else archive_page(url); fetch_cleanup(); free(base_domain); return 0; }