# Psilocybin Research Tracker: bibliometrics + visNetwork citation map # # This script downloads the current public SQLite database from # https://psilocybin-research.com/database.php, runs local bibliometric # summaries, and writes an interactive HTML citation network plus optional # PDF/PNG snapshots. # # Usage: # Rscript psilocybin_bibliometrics_visnetwork.R # # Optional environment variables: # PSILO_DB_URL Database URL. Default: https://psilocybin-research.com/database.php # PSILO_OUTPUT_DIR Output folder. Default: psilocybin_bibliometrics_output # PSILO_MAX_PAPERS Max seed papers for the citation network. Default: 300 # PSILO_FROM_YEAR Optional minimum publication year, e.g. 2020 # PSILO_STATUS Optional comma list: published,preprint,clinical trial,protocol,review # PSILO_TOPIC Optional topic substring filter, e.g. depression # PSILO_SUBSTANCE Optional substance substring filter, e.g. psilocybin # PSILO_AUTO_OPEN Open finished HTML report/network. Default: 1 options(stringsAsFactors = FALSE, repos = c(CRAN = "https://cloud.r-project.org")) if (!requireNamespace("pacman", quietly = TRUE)) { install.packages("pacman") } pacman::p_load( DBI, RSQLite, dplyr, tidyr, stringr, lubridate, httr2, jsonlite, purrr, readr, ggplot2, igraph, visNetwork, htmlwidgets, htmltools, DT, webshot2 ) cfg <- list( db_url = Sys.getenv("PSILO_DB_URL", "https://psilocybin-research.com/database.php"), output_dir = Sys.getenv("PSILO_OUTPUT_DIR", "psilocybin_bibliometrics_output"), max_papers = as.integer(Sys.getenv("PSILO_MAX_PAPERS", "300")), from_year = Sys.getenv("PSILO_FROM_YEAR", ""), status = Sys.getenv("PSILO_STATUS", ""), topic = Sys.getenv("PSILO_TOPIC", ""), substance = Sys.getenv("PSILO_SUBSTANCE", ""), auto_open = !tolower(Sys.getenv("PSILO_AUTO_OPEN", "1")) %in% c("0", "false", "no", "off") ) if (is.na(cfg$max_papers) || cfg$max_papers < 20) cfg$max_papers <- 300 dir.create(cfg$output_dir, recursive = TRUE, showWarnings = FALSE) dir.create(file.path(cfg$output_dir, "tables"), recursive = TRUE, showWarnings = FALSE) dir.create(file.path(cfg$output_dir, "figures"), recursive = TRUE, showWarnings = FALSE) dir.create(file.path(cfg$output_dir, "network"), recursive = TRUE, showWarnings = FALSE) download_database <- function(url, dest) { if (str_starts(url, "file://")) { local_path <- sub("^file://", "", url) if (!file.copy(local_path, dest, overwrite = TRUE)) { stop("Could not copy local database file: ", local_path) } return(invisible(dest)) } result <- tryCatch({ request <- httr2::request(url) |> httr2::req_user_agent("PsilocybinResearchTrackerR/1.0 (+https://psilocybin-research.com/)") |> httr2::req_timeout(180) response <- httr2::req_perform(request, path = dest) if (httr2::resp_status(response) >= 400) { stop("HTTP ", httr2::resp_status(response)) } TRUE }, error = function(e) { message("httr2 download failed: ", conditionMessage(e)) FALSE }) if (!isTRUE(result)) { utils::download.file( url, dest, mode = "wb", quiet = FALSE, headers = c("User-Agent" = "PsilocybinResearchTrackerR/1.0 (+https://psilocybin-research.com/)") ) } if (!file.exists(dest) || file.info(dest)$size < 1024) { stop("Database download failed or returned an unexpectedly small file.") } invisible(dest) } message("Downloading current database from: ", cfg$db_url) db_file <- file.path(cfg$output_dir, "psilocybin-research-publications.sqlite") download_database(cfg$db_url, db_file) con <- DBI::dbConnect(RSQLite::SQLite(), db_file) on.exit(DBI::dbDisconnect(con), add = TRUE) table_exists <- function(name) { name %in% DBI::dbListTables(con) } normalize_doi <- function(x) { x <- tolower(trimws(as.character(x))) x <- str_replace(x, "^https?://(dx\\.)?doi\\.org/", "") x <- str_replace(x, "^doi:\\s*", "") x[x == "" | is.na(x)] <- NA_character_ x } split_csv_field <- function(x) { x <- as.character(x) if (length(x) == 0 || is.na(x) || !nzchar(trimws(x))) return(character()) str_split(x, "\\s*,\\s*|\\s*;\\s*")[[1]] |> trimws() |> discard(~ .x == "") } extract_reference_dois <- function(raw_json) { if (is.na(raw_json) || !nzchar(raw_json)) return(character()) raw <- tryCatch(jsonlite::fromJSON(raw_json, simplifyVector = FALSE), error = function(e) NULL) if (is.null(raw)) return(character()) values <- character() for (key in c("reference_dois", "references")) { items <- raw[[key]] if (is.null(items)) next if (is.character(items)) { values <- c(values, items) } else if (is.list(items)) { values <- c(values, purrr::map_chr(items, function(item) { if (is.character(item) && length(item) == 1) return(item) if (is.list(item) && !is.null(item$doi)) return(as.character(item$doi)) NA_character_ })) } } unique(na.omit(normalize_doi(values))) } safe_slug <- function(x) { x |> str_to_lower() |> str_replace_all("[^a-z0-9]+", "-") |> str_replace_all("(^-|-$)", "") } ensure_column <- function(data, name, default = NA_character_) { if (!name %in% names(data)) data[[name]] <- default data } publication_table <- DBI::dbReadTable(con, "publications") |> as_tibble() for (column in c( "id", "title", "authors", "journal", "publication_date", "publication_year", "doi", "pubmed_id", "source_url", "source_name", "publication_status", "topic_tags", "substance_tags", "study_type", "raw_json" )) { publication_table <- ensure_column(publication_table, column) } publications <- publication_table |> as_tibble() |> mutate( id = as.integer(id), publication_year = suppressWarnings(as.integer(publication_year)), publication_date = suppressWarnings(as.Date(publication_date)), doi_norm = normalize_doi(doi), title = coalesce(title, ""), authors = coalesce(authors, ""), journal = na_if(trimws(coalesce(journal, "")), ""), source_name = coalesce(source_name, ""), publication_status = coalesce(publication_status, "published"), topic_tags = coalesce(topic_tags, ""), substance_tags = coalesce(substance_tags, ""), study_type = coalesce(study_type, ""), raw_json = coalesce(raw_json, "") ) if ("hidden" %in% names(publications)) { publications <- publications |> filter(is.na(hidden) | as.integer(hidden) == 0) } if ("false_positive" %in% names(publications)) { publications <- publications |> filter(is.na(false_positive) | as.integer(false_positive) == 0) } if (nzchar(cfg$from_year)) { min_year <- suppressWarnings(as.integer(cfg$from_year)) if (!is.na(min_year)) { publications <- publications |> filter(is.na(publication_year) | publication_year >= min_year) } } if (nzchar(cfg$status)) { status_filter <- cfg$status |> str_split("\\s*,\\s*") |> unlist() |> str_to_lower() publications <- publications |> filter(str_to_lower(publication_status) %in% status_filter) } if (nzchar(cfg$topic)) { publications <- publications |> filter(str_detect(str_to_lower(topic_tags), fixed(str_to_lower(cfg$topic)))) } if (nzchar(cfg$substance)) { publications <- publications |> filter(str_detect(str_to_lower(substance_tags), fixed(str_to_lower(cfg$substance)))) } if (nrow(publications) == 0) { stop("No publications match the current filters.") } authors <- if (table_exists("publication_authors")) { DBI::dbReadTable(con, "publication_authors") |> as_tibble() |> transmute(publication_id = as.integer(publication_id), author_name = trimws(author_name), position = as.integer(position)) |> inner_join(publications |> select(id), by = c("publication_id" = "id")) |> filter(nzchar(author_name)) } else { publications |> select(publication_id = id, authors) |> mutate(author_name = map(authors, split_csv_field)) |> unnest(author_name) |> group_by(publication_id) |> mutate(position = row_number()) |> ungroup() } topics <- if (table_exists("publication_topics")) { DBI::dbReadTable(con, "publication_topics") |> as_tibble() |> transmute(publication_id = as.integer(publication_id), topic = trimws(topic), source = coalesce(source, "classifier")) |> inner_join(publications |> select(id), by = c("publication_id" = "id")) |> filter(nzchar(topic)) } else { publications |> select(publication_id = id, topic_tags) |> mutate(topic = map(topic_tags, split_csv_field)) |> unnest(topic) |> mutate(source = "classifier") } references <- publications |> select(publication_id = id, source_doi = doi_norm, title, publication_year, raw_json) |> mutate(reference_doi = map(raw_json, extract_reference_dois)) |> select(-raw_json) |> unnest(reference_doi) |> filter(!is.na(source_doi), !is.na(reference_doi), source_doi != reference_doi) |> distinct(publication_id, source_doi, reference_doi, .keep_all = TRUE) internal_dois <- publications |> filter(!is.na(doi_norm)) |> select(target_id = id, reference_doi = doi_norm, target_title = title) citation_edges <- references |> left_join(internal_dois, by = "reference_doi") |> mutate(edge_scope = if_else(is.na(target_id), "external_reference", "internal_citation")) year_summary <- publications |> count(publication_year, sort = TRUE, name = "records") |> arrange(publication_year) status_summary <- publications |> count(publication_status, sort = TRUE, name = "records") journal_summary <- publications |> mutate(journal = coalesce(journal, "Unknown journal")) |> count(journal, sort = TRUE, name = "records") author_summary <- authors |> count(author_name, sort = TRUE, name = "records") topic_summary <- topics |> count(topic, sort = TRUE, name = "records") study_type_summary <- publications |> mutate(study_type = if_else(nzchar(study_type), study_type, "Unclassified")) |> count(study_type, sort = TRUE, name = "records") source_summary <- publications |> mutate(source_name = if_else(nzchar(source_name), source_name, "Unknown source")) |> count(source_name, sort = TRUE, name = "records") readr::write_csv(publications, file.path(cfg$output_dir, "tables", "publications.csv")) readr::write_csv(authors, file.path(cfg$output_dir, "tables", "publication_authors.csv")) readr::write_csv(topics, file.path(cfg$output_dir, "tables", "publication_topics.csv")) readr::write_csv(citation_edges, file.path(cfg$output_dir, "tables", "citation_edges.csv")) readr::write_csv(year_summary, file.path(cfg$output_dir, "tables", "records_by_year.csv")) readr::write_csv(journal_summary, file.path(cfg$output_dir, "tables", "top_journals.csv")) readr::write_csv(author_summary, file.path(cfg$output_dir, "tables", "top_authors.csv")) readr::write_csv(topic_summary, file.path(cfg$output_dir, "tables", "top_topics.csv")) readr::write_csv(status_summary, file.path(cfg$output_dir, "tables", "status_summary.csv")) readr::write_csv(source_summary, file.path(cfg$output_dir, "tables", "source_summary.csv")) readr::write_csv(study_type_summary, file.path(cfg$output_dir, "tables", "study_type_summary.csv")) plot_years <- ggplot(year_summary |> filter(!is.na(publication_year)), aes(publication_year, records)) + geom_col(fill = "#2f6f5e", width = 0.8) + geom_line(color = "#7a5c2e", linewidth = 0.8) + geom_point(color = "#123c31", size = 1.8) + labs(title = "Psilocybin/psilocin records by publication year", x = "Publication year", y = "Records") + theme_minimal(base_size = 12) plot_journals <- journal_summary |> slice_head(n = 20) |> mutate(journal = reorder(journal, records)) |> ggplot(aes(records, journal)) + geom_col(fill = "#366f91") + labs(title = "Top journals", x = "Records", y = NULL) + theme_minimal(base_size = 12) plot_topics <- topic_summary |> slice_head(n = 20) |> mutate(topic = reorder(topic, records)) |> ggplot(aes(records, topic)) + geom_col(fill = "#7a5c2e") + labs(title = "Top research topics", x = "Records", y = NULL) + theme_minimal(base_size = 12) ggsave(file.path(cfg$output_dir, "figures", "records_by_year.pdf"), plot_years, width = 10, height = 6) ggsave(file.path(cfg$output_dir, "figures", "top_journals.pdf"), plot_journals, width = 10, height = 7) ggsave(file.path(cfg$output_dir, "figures", "top_topics.pdf"), plot_topics, width = 10, height = 7) seed_papers <- publications |> mutate(reference_count = map_int(raw_json, ~ length(extract_reference_dois(.x)))) |> arrange(desc(reference_count), desc(publication_year), desc(id)) |> slice_head(n = cfg$max_papers) paper_nodes <- seed_papers |> transmute( id = paste0("paper:", id), label = str_trunc(title, 72), title = paste0( "", htmltools::htmlEscape(title), "
", htmltools::htmlEscape(coalesce(authors, "")), "
", htmltools::htmlEscape(coalesce(journal, "Unknown journal")), " ", coalesce(as.character(publication_year), ""), "
", "Status: ", htmltools::htmlEscape(publication_status), "
", if_else(!is.na(doi_norm), paste0("DOI: ", htmltools::htmlEscape(doi_norm)), "") ), group = "Paper", value = pmax(8, pmin(40, reference_count + 8)), url = if_else(!is.na(doi_norm), paste0("https://doi.org/", doi_norm), coalesce(source_url, "")), publication_year = publication_year ) seed_refs <- seed_papers |> select(publication_id = id, source_doi = doi_norm, title, raw_json) |> mutate(reference_doi = map(raw_json, extract_reference_dois)) |> select(-raw_json) |> unnest(reference_doi) |> filter(!is.na(reference_doi)) |> distinct(publication_id, reference_doi) reference_counts <- seed_refs |> count(reference_doi, sort = TRUE, name = "cited_by_seed_count") |> slice_head(n = max(75, min(500, cfg$max_papers * 3))) reference_nodes <- reference_counts |> left_join(publications |> select(doi_norm, matched_id = id, matched_title = title, matched_year = publication_year), by = c("reference_doi" = "doi_norm")) |> transmute( id = paste0("ref:", safe_slug(reference_doi)), label = if_else(!is.na(matched_title), str_trunc(matched_title, 64), str_trunc(reference_doi, 48)), title = paste0( if_else(!is.na(matched_title), paste0("", htmltools::htmlEscape(matched_title), "
"), ""), "Reference DOI: ", htmltools::htmlEscape(reference_doi), "
", "Cited by seed papers: ", cited_by_seed_count ), group = if_else(is.na(matched_id), "External DOI reference", "Indexed cited paper"), value = pmax(6, pmin(36, cited_by_seed_count * 3)), url = paste0("https://doi.org/", reference_doi), publication_year = matched_year ) network_edges <- seed_refs |> semi_join(reference_counts, by = "reference_doi") |> transmute( from = paste0("paper:", publication_id), to = paste0("ref:", safe_slug(reference_doi)), arrows = "to", title = "cites", color = "#a6b5ad" ) author_edges <- authors |> semi_join(seed_papers |> select(id), by = c("publication_id" = "id")) |> group_by(author_name) |> filter(n() >= 2) |> ungroup() |> mutate(author_id = paste0("author:", safe_slug(author_name))) |> transmute(from = paste0("paper:", publication_id), to = author_id, title = "author", color = "#d7c9a0", arrows = "") author_nodes <- author_edges |> distinct(id = to) |> mutate( label = str_replace(id, "^author:", "") |> str_replace_all("-", " ") |> str_to_title(), title = paste0("Author: ", htmltools::htmlEscape(label)), group = "Author", value = 10, url = "" ) nodes <- bind_rows(paper_nodes, reference_nodes, author_nodes) |> distinct(id, .keep_all = TRUE) edges <- bind_rows(network_edges, author_edges) |> filter(from %in% nodes$id, to %in% nodes$id) |> distinct(from, to, title, .keep_all = TRUE) graph <- igraph::graph_from_data_frame(edges |> select(from, to), directed = TRUE, vertices = nodes |> select(id)) centrality <- tibble(id = names(igraph::degree(graph, mode = "all")), degree = as.numeric(igraph::degree(graph, mode = "all"))) nodes <- nodes |> left_join(centrality, by = "id") |> mutate( degree = coalesce(degree, 0), value = pmax(value, pmin(45, 6 + degree * 2)), borderWidth = if_else(group == "Paper", 2, 1) ) readr::write_csv(nodes, file.path(cfg$output_dir, "tables", "network_nodes.csv")) readr::write_csv(edges, file.path(cfg$output_dir, "tables", "network_edges.csv")) network_html <- visNetwork::visNetwork(nodes, edges, width = "100%", height = "850px", main = "Psilocybin Research Tracker citation network") |> visNetwork::visGroups(groupname = "Paper", color = list(background = "#2f6f5e", border = "#123c31"), shape = "dot") |> visNetwork::visGroups(groupname = "Indexed cited paper", color = list(background = "#366f91", border = "#19384d"), shape = "dot") |> visNetwork::visGroups(groupname = "External DOI reference", color = list(background = "#e8dfc4", border = "#7a5c2e"), shape = "dot") |> visNetwork::visGroups(groupname = "Author", color = list(background = "#f2c879", border = "#7a5c2e"), shape = "triangle") |> visNetwork::visOptions(highlightNearest = list(enabled = TRUE, degree = 1, hover = TRUE), nodesIdSelection = TRUE, selectedBy = "group") |> visNetwork::visInteraction(navigationButtons = TRUE, keyboard = TRUE, hover = TRUE, tooltipDelay = 80) |> visNetwork::visPhysics( solver = "forceAtlas2Based", forceAtlas2Based = list(gravitationalConstant = -80, centralGravity = 0.02, springLength = 140, springConstant = 0.05), stabilization = list(enabled = TRUE, iterations = 800) ) |> visNetwork::visLayout(randomSeed = 42) |> visNetwork::visLegend(width = 0.2, position = "right", main = "Node type") network_file <- file.path(cfg$output_dir, "network", "psilocybin_citation_network.html") htmlwidgets::saveWidget(network_html, network_file, selfcontained = TRUE, title = "Psilocybin citation network") pdf_file <- file.path(cfg$output_dir, "network", "psilocybin_citation_network.pdf") png_file <- file.path(cfg$output_dir, "network", "psilocybin_citation_network.png") snapshot_result <- tryCatch({ webshot2::webshot(network_file, file = pdf_file, vwidth = 1600, vheight = 1100, zoom = 0.8, delay = 2) webshot2::webshot(network_file, file = png_file, vwidth = 1600, vheight = 1100, zoom = 0.8, delay = 2) "PDF and PNG network snapshots created." }, error = function(e) { paste("Interactive HTML was created. PDF/PNG snapshot skipped because webshot2 could not access a browser:", conditionMessage(e)) }) summary_html <- htmltools::tagList( htmltools::tags$html( htmltools::tags$head( htmltools::tags$meta(charset = "utf-8"), htmltools::tags$title("Psilocybin Research Tracker bibliometrics"), htmltools::tags$style(htmltools::HTML(" body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:0;background:#f7f5ee;color:#1d2b25} main{max-width:1120px;margin:0 auto;padding:32px} h1{font-size:34px;margin-bottom:4px} h2{margin-top:32px} .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:24px 0} .card{background:white;border:1px solid #e2dac8;border-radius:10px;padding:16px} .card strong{display:block;font-size:28px;color:#2f6f5e} a{color:#1a6b54;font-weight:700}.note{color:#68746d}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px} @media(max-width:760px){.grid{grid-template-columns:1fr}main{padding:20px}} ")) ), htmltools::tags$body( htmltools::tags$main( htmltools::tags$p(class = "note", "Based on the live public SQLite database downloaded from psilocybin-research.com."), htmltools::tags$h1("Psilocybin Research Tracker bibliometrics"), htmltools::tags$div(class = "cards", htmltools::tags$div(class = "card", htmltools::tags$strong(nrow(publications)), htmltools::tags$span("records analyzed")), htmltools::tags$div(class = "card", htmltools::tags$strong(n_distinct(publications$journal, na.rm = TRUE)), htmltools::tags$span("journals")), htmltools::tags$div(class = "card", htmltools::tags$strong(n_distinct(authors$author_name, na.rm = TRUE)), htmltools::tags$span("authors")), htmltools::tags$div(class = "card", htmltools::tags$strong(nrow(references)), htmltools::tags$span("reference DOI edges")) ), htmltools::tags$p(htmltools::tags$a(href = "network/psilocybin_citation_network.html", "Open interactive citation network")), htmltools::tags$p(class = "note", snapshot_result), htmltools::tags$h2("Top journals"), DT::datatable(journal_summary |> slice_head(n = 25), rownames = FALSE, options = list(pageLength = 10)), htmltools::tags$h2("Top authors"), DT::datatable(author_summary |> slice_head(n = 25), rownames = FALSE, options = list(pageLength = 10)), htmltools::tags$h2("Top topics"), DT::datatable(topic_summary |> slice_head(n = 25), rownames = FALSE, options = list(pageLength = 10)) ) ) ) ) summary_file <- file.path(cfg$output_dir, "bibliometric_report.html") htmltools::save_html(summary_html, summary_file) open_output_file <- function(path) { path <- normalizePath(path, mustWork = FALSE) if (!file.exists(path)) return(invisible(FALSE)) opened <- tryCatch({ utils::browseURL(path) TRUE }, error = function(e) { message("Could not auto-open ", path, ": ", conditionMessage(e)) FALSE }) invisible(opened) } if (isTRUE(cfg$auto_open)) { message("Opening report and interactive network in your default browser.") open_output_file(summary_file) open_output_file(network_file) } message("\nDone.") message("Records analyzed: ", nrow(publications)) message("Citation/reference edges: ", nrow(references)) message("Interactive network: ", normalizePath(network_file, mustWork = FALSE)) message("Bibliometric report: ", normalizePath(summary_file, mustWork = FALSE)) message(snapshot_result)