Case study: measuring an internal-linking intervention
Bart Turczynski
2026-07-26
Source:vignettes/case-study.Rmd
case-study.RmdDiagnose, model, ship, re-measure
Most link-graph analysis stops at the diagnosis. This vignette runs the whole loop on one small site: a problem was found in a crawl, a fix was modeled, the fix shipped, and the site was crawled again. Both crawls ship with the package, so every number below is computed as you read it rather than quoted from a report.
The site is a 67-page reviews microsite. The diagnosis was that four
sitewide in-content links — methodology and disclosure bylines repeated
under every article — were hoarding editorial authority and returning
none of it. The fix had two parts: move those bylines into a semantic
<nav> so they stop counting as content, and add
genuine body links into the most-orphaned commercial pages.
The interesting result is not that it worked. It is that whether you can see it working at all depends entirely on which graph you score.
Loading the two crawls
The fixture is two Screaming Frog exports per phase —
internal_all.csv for the page inventory and
all_inlinks.csv for the links.
crawl <- function(phase) {
dir <- system.file(
"extdata", paste0("reviews-microsite-", phase),
package = "pagerankr"
)
screaming_frog_bundle(
internal = file.path(dir, "internal_all.csv"),
links = file.path(dir, "all_inlinks.csv"),
link_export_kind = "all_inlinks"
)
}
before <- crawl("before")
after <- crawl("after")
c(before = nrow(before$edges), after = nrow(after$edges))
#> before after
#> 3820 3599The site is pseudonymous: hosts, paths, anchors and titles were
replaced by a deterministic relabeling, while everything structural —
status codes, crawl depth, link counts, Link Position and
Link Path — is verbatim. Paths like
/s2/p06/p05/ preserve depth and sibling grouping and carry
no meaning. inst/extdata/README.md documents the scheme in
full.
Two lenses on the same crawl
pagerank_screaming_frog() scores the whole graph by
default. Passing accepted_placements = "Content" restricts
it to links Screaming Frog placed in the body — the
editorial graph, the subset a human actually chose per
page.
score <- function(bundle, ...) pagerank_screaming_frog(bundle, ...)
ed_before <- score(before, accepted_placements = "Content")
ed_after <- score(after, accepted_placements = "Content")
full_before <- score(before)
full_after <- score(after)External hosts get scored too. For site-level reporting we want internal pages only, but note that rank positions below are taken over the full scored set, externals included — that is the ranking the pipeline actually produces, and trimming it first would quietly renumber everything.
internal <- function(x) {
x[grepl("reviews-microsite", x$node_name, fixed = TRUE), ]
}
c(
editorial_before = nrow(internal(ed_before)),
editorial_after = nrow(internal(ed_after)),
full_before = nrow(internal(full_before)),
full_after = nrow(internal(full_after))
)
#> editorial_before editorial_after full_before full_after
#> 67 62 67 62Sixty-seven pages before, sixty-two after. Five pages were retired during the work. Hold that number — it is the main reason the naive comparison is a trap.
PageRank does not sum to 1 here
Worth stating before any share is computed, because it silently breaks the obvious arithmetic:
c(
editorial_before = sum(internal(ed_before)$pagerank),
editorial_after = sum(internal(ed_after)$pagerank)
)
#> editorial_before editorial_after
#> 0.8235217 0.8885489Mass that reaches a page with no onward links leaves the distribution
rather than being recycled; pagerankr accounts for it
separately as wasted mass instead of quietly redistributing it. So
every share must be taken against
sum(pagerank), never assumed to be 1.
That the total rose is itself part of the result: draining the sinks meant less mass ended up stranded.
The headline: concentration collapsed
A de-sink makes one specific prediction — authority stops piling onto a few hoarding nodes and spreads across the rest. Concentration metrics test exactly that, and they are robust to the node set changing underneath them.
top_n_share <- function(x, n = 5) {
sum(sort(x, decreasing = TRUE)[seq_len(n)]) / sum(x)
}
concentration <- function(x) {
pr <- internal(x)$pagerank
c(
n = length(pr),
gini = round(pr_gini(pr), 3),
entropy = round(pr_entropy(pr), 2),
top5_share = round(100 * top_n_share(pr), 1)
)
}
rbind(
before = concentration(ed_before),
after = concentration(ed_after)
)
#> n gini entropy top5_share
#> before 67 0.715 2.70 71.0
#> after 62 0.589 3.39 47.7Gini fell from 0.715 to 0.589, entropy rose, and the top five pages went from holding about 71% of editorial authority to about 48%.
Note top_n_share() is written by hand above.
pr_top_k_share() exists, but its k is a
fraction of nodes, not a count —
pr_top_k_share(x, 0.1) is the top 10%. Passing
5 is an error, not a top-5.
Where the authority went
Now the per-page view. Two pieces of discipline are load-bearing here.
Match on the full URL, not the path. External hosts share path strings with internal ones, so keying on the path merges distinct nodes and silently duplicates rows.
Compare relative change, not absolute level. The node set changed, so a raw difference in PageRank mixes the intervention with the arithmetic of a smaller graph.
ranked <- function(x) {
d <- data.frame(
url = x$node_name,
pr = x$pagerank,
stringsAsFactors = FALSE
)
d$rank <- rank(-d$pr, ties.method = "min")
d
}
moves <- merge(
ranked(ed_before), ranked(ed_after),
by = "url", suffixes = c("_before", "_after")
)
moves$change <- round(100 * (moves$pr_after / moves$pr_before - 1))
# Rank over everything scored; report internal pages only.
moves <- moves[grepl("reviews-microsite", moves$url, fixed = TRUE), ]
nrow(moves)
#> [1] 62Sixty-two pages appear in both crawls. The four biggest losses:
show <- function(d) {
d$page <- sub("^https://[^/]+", "", d$url)
cols <- c(
"page", "pr_before", "pr_after", "change",
"rank_before", "rank_after"
)
out <- d[, cols]
out$pr_before <- round(out$pr_before, 3)
out$pr_after <- round(out$pr_after, 3)
print(out, row.names = FALSE)
}
show(head(moves[order(moves$change), ], 4))
#> page pr_before pr_after change rank_before rank_after
#> /s2/p01/p05/ 0.242 0.005 -98 1 38
#> /s2/p01/p02/ 0.108 0.004 -96 3 49
#> /s2/p01/p01/ 0.186 0.009 -95 2 20
#> /s2/p01/p04/ 0.024 0.005 -81 5 46All four are children of /s2/p01/ — the branch the
sitewide bylines pointed into. The top-ranked page in the site lost 98%
of its editorial score and fell from #1 to #38.
The other side:
show(head(moves[order(-moves$change), ], 6))
#> page pr_before pr_after change rank_before rank_after
#> /s2/p06/p01/ 0.006 0.139 2295 16 1
#> /s2/p06/p02/ 0.005 0.126 2225 18 2
#> /s2/p06/p05/ 0.003 0.046 1580 59 4
#> /s2/p08/p01/ 0.007 0.073 905 8 3
#> /s2/p06/p04/ 0.003 0.022 615 46 9
#> /s2/p08/p04/ 0.004 0.027 608 28 7The gainers sit in /s2/p06/ and /s2/p08/ —
the commercial branches the feeder links were pointed at. That is the
shape a de-sink is supposed to have: authority leaving one hoarding
branch and landing across many pages, not moving to a new favorite.
The mechanism was reclassification, not new links
The largest single lever was not the links that were added. It was the links that stopped counting.
Moving the bylines into
<nav aria-label="Editorial standards"> makes
Screaming Frog record their Link Position as
Navigation instead of Content. They do not
lose weight — they leave the editorial graph
entirely.
content_edges <- function(bundle) {
e <- bundle$edges
e <- e[!is.na(e$link_position) & e$link_position == "Content", ]
keep <- grepl("reviews-microsite", e$from, fixed = TRUE) &
grepl("reviews-microsite", e$to, fixed = TRUE)
nrow(unique(e[keep, c("from", "to")]))
}
c(before = content_edges(before), after = content_edges(after))
#> before after
#> 406 307Ninety-nine internal content edges disappeared, and almost none of
the HTML changed. This is the practical corollary of the boilerplate
problem — see vignette("boilerplate") — in-content template
links are a second navigation, and the cheapest correct fix is to make
the markup say so.
The full graph barely noticed
Now score both crawls without the placement filter.
full <- merge(
ranked(full_before), ranked(full_after),
by = "url", suffixes = c("_before", "_after")
)
full <- full[grepl("reviews-microsite", full$url, fixed = TRUE), ]
c(
pages = nrow(full),
gini_before = round(pr_gini(internal(full_before)$pagerank), 3),
gini_after = round(pr_gini(internal(full_after)$pagerank), 3),
pearson = round(cor(full$pr_before, full$pr_after), 4)
)
#> pages gini_before gini_after pearson
#> 62.0000 0.2970 0.2570 0.9999Pearson correlation of 0.9999 across the pages present in both crawls. To four decimal places, the full-graph ranking is the same vector before and after.
An intervention that moved a page from #1 to #38 on one lens is invisible on the other. The site has roughly 3,300 navigation edges against 400 content ones, so the template dominates the full graph and swamps any editorial change. The lens decides whether the change exists at all — which is the argument for running the editorial view as a matter of course, not as a special case.
Confounds — read this before reusing the method
This is a two-crawl natural experiment, not a controlled simulation on a fixed graph. Three limits apply, and stating them is part of the method:
The node set changed. Five pages were retired between crawls. Absolute editorial PageRank is therefore not comparable across the pair — read rank shifts, concentration metrics, and relative deltas instead.
Full-graph absolute levels drift for a mechanical reason. Individual pages rise 1–23% in the full graph purely because those five pages left and their mass redistributed. That spread is a node-set artifact, not a response to the intervention. The correlation is the honest statistic; the per-page percentages there are not.
Two changes shipped together. The byline reclassification and the new feeder links landed in the same release, so what is measured is the net effect. Separating them requires modeling each on a fixed graph — which is what
simulate_changes()is for.
On that last point: the pre-ship simulate_changes()
projection agreed with this re-crawl in direction and rough magnitude.
That agreement, on a real site, is the strongest claim available — the
diagnosis was not merely internally consistent, it predicted something
that then happened.
Running this on your own crawls
The whole method is four decisions:
- Export
Internal > AllandBulk Export > Links > All Inlinksat both time points, and keepLink PositionandLink Path. - Score twice per crawl — once with
accepted_placements = "Content", once without. Differences between the two lenses are the finding, not noise. - Report concentration (Gini, entropy, top-N share) and rank movement. Do not report absolute PageRank across crawls whose node sets differ.
- Check
sum(pagerank)rather than assuming it is 1, and match pages on the full URL.
For the modeling half of the loop — projecting a change before
shipping it — see vignette("pagerankr-usage") and
simulate_changes(). For the placement weighting used to
soften rather than exclude template links, see
vignette("presets").