The test suite passes against rurl 3.1.0 as well as 3.0.1. rurl 3.1.0 adds
path_normalisation, a British-spelling alias of path_normalization, to
rurl::get_clean_url(), and the canonicalization surface guard in
test-canonicalization.R failed on it by design. The guard now lists the
alias as an optional argument: it counts as accounted for when the installed rurl has
it and is never reported as removed when it does not. It cannot move the node
key, because canonical_profile() pins path_normalization and rurl rejects
both spellings together (PAGE-lgsbjjuf).
analyse_pagerank_grid() and sf_normalise_position() are now exported as
British-spelling aliases of analyze_pagerank_grid() and
sf_normalize_position(). Each alias is the same function as its US-spelled
primary and is documented on the primary's help page; the US spellings stay
the canonical names (SEOR-qwomlgjd).
Documentation prose is now US English throughout, matching DESCRIPTION's
Language: en-US: the help pages for smooth_transitions() and
transform_weights() and the README function table no longer use British
spellings, and inst/WORDLIST no longer accepts them. The message
compute_pagerank() prints when eps or niter switches the solver to
ARPACK now reads "honored" (SEOR-kfiqpymb).
The documentation site's address is now the GitLab Pages namespace path
https://bart-turczynski.gitlab.io/pagerankr/, the fleet-wide standard,
instead of the project's unique Pages domain, which stops resolving once it
is turned off. DESCRIPTION, _pkgdown.yml, CITATION.cff, .zenodo.json,
codemeta.json, SECURITY-INSIGHTS.yml, .bestpractices.json and the
README badge all carry the new address. The CRAN page for 0.1.0 keeps the
old address until the next release (SEOR-vujgdjfv).
pagerankr is on CRAN. DESCRIPTION's URL: now lists the CRAN page,
https://CRAN.R-project.org/package=pagerankr, as do CITATION.cff and
codemeta.json, and the README leads its installation instructions with
install.packages("pagerankr") (PAGE-gwgjmtmw).
BugReports: stays on /-/issues; every human-facing tracker link now
points at /-/work_items. CRAN runs two checks over a GitLab
BugReports: URL and they contradict each other: tools::check_url_db()
fetches the address and 404s on /-/issues, which GitLab replaced with a
new work-items page, while tools:::.check_package_CRAN_incoming() never
fetches anything and flags any path that is not a string match for
/-/issues with a NOTE. Declaring /-/work_items in DESCRIPTION is not
a style choice: it got the first pslr 1.2.1 upload archived at the CRAN
pretest on 2026-09-12. So DESCRIPTION (and the roxygen2-generated
pagerankr-package help topic, which mirrors it) keep /-/issues;
codemeta.json, SECURITY.md, and README -- files a human actually
clicks -- now point at /-/work_items, the address GitLab serves
(SEOR-ocbtrrnl).
The GitHub Actions workflows are gone, and codemeta.json names GitLab.
.gitlab-ci.yml has replaced all eight workflows since the move to GitLab, so
.github/workflows/ and .github/dependabot.yml were dead weight that the new
read-only GitHub mirror would have republished. codemeta.json now points at
gitlab.com for every URL it carries -- repository, issue tracker, release
notes and CI -- matching what DESCRIPTION has said since PAGE-mehcgwzv (PAGE-yfmrrrhp).
BugReports: points at /-/issues, the form the CRAN incoming
check requires. The incoming check on R-devel notes any gitlab.com BugReports:
whose path is not /-/issues; a browser is redirected to /-/work_items.
README and the generated pagerankr-package help topic follow
(PAGE-mehcgwzv).
rurl now installs from CRAN; the Remotes: field is gone. rurl
reached CRAN as 3.0.1 on 2026-09-09, so DESCRIPTION no longer needs a
Remotes: entry -- a field CRAN does not honor and which drew a NOTE on
every --as-cran run. Every hard dependency now resolves from CRAN
(PAGE-agtgxupt).
The declared rurl floor is >= 3.0.1, up from >= 3.0.0. rurl went
1.2.0 straight to 3.0.1 on CRAN and never published a 3.0.0, so the old floor
named a version nobody could install -- the second time this package declared
a minimum that existed nowhere. 3.0.1 is the lowest rurl that exists as a
release and that the suite has been run against (PAGE-tsbkxhoz).
canonical_profile() now pins credential_handling = "strip". rurl
3.0.1 added the argument. Userinfo is not part of a node key, but this knob
decides whether a credentialed URL yields a key at all: the alternative,
"reject", returns NA, which clean_url_columns() keeps as its raw self
and so turns every credentialed URL into an opaque node. "strip" is
rurl's current default, so no node key changes -- the pin freezes a default
that could otherwise flip underneath the graph.
The package is now hosted on GitLab, and the install command has changed
to devtools::install_gitlab("bart-turczynski/pagerankr"). The previous
install_github() form no longer resolves. Bug reports move to the GitLab
tracker, and security reports are now made by email to the maintainer rather
than through GitHub private vulnerability reporting (see SECURITY.md).
Convergence controls and damping now fail as documented instead of
silently changing the request or raising an internal R error. Three fixes,
all at the public boundary of both pagerank() and compute_pagerank():
A fractional niter is rejected. It was passed through as.integer(),
so niter = 2.7 became an iteration cap of 2 with nothing said. Values
above .Machine$integer.max are rejected for the same reason: the
coercion produced NA and reached the solver as a missing cap.
damping = NA_real_ (and NA_integer_, NaN, Inf, -Inf) reports
`damping` must be a single numeric value between 0 and 1.
compute_pagerank() tested damping < 0 on an NA, so the if received
NA and R raised "missing value where TRUE/FALSE needed". Bare NA is
logical and already produced the named error; only the typed missings and
the infinities slipped through. Both entry points now share one validator.A non-finite eps is rejected. Inf passed the eps <= 0 test, and an
infinite tolerance is not a tolerance.
Added deterministic fixtures for graph shapes whose convergence and mass diagnostics were unpinned: disconnected components (a closed component holds exactly its share of the teleport vector; a dangling node moves mass across the component boundary), zero-weight edges (equivalent to no edge, with the target left on teleport mass alone), and the all-zero-weight graph (a named weight error).
Node identity moves off a presentation dial: canonical_profile() now pins
url_standard = "whatwg" and path_encoding = "keep". This changes node
keys. path_encoding is documented by rurl as a presentation knob whose
"decode" value "may re-encode or decode reserved octets (so %2F may fold
to a path-separating /)"; pagerankr had been using it as its identity
function. Two consequences, both defects, are fixed:
An encoded slash is no longer folded into a path separator, so
http://example.com/a%2Fb and http://example.com/a/b are two nodes
rather than one. Same for http://example.com/..%2fa, which previously
popped a segment it should not have.
rurl 3.0.0 stopped resolving
them under the old pin by reordering decode after dot-segment removal, so
/%2e%2e/a keyed to /../a instead of /a. Under "whatwg" the standard
recognizes the encoded form directly and no presentation dial is involved.Three further classes improve for crawl data: paths containing a tab or
newline, and URLs with surrounding whitespace, now parse (they previously
returned NA and fell through to the raw-token fallback), and a literal
space is percent-encoded rather than failing the parse. The cost is that
"whatwg" preserves percent spellings, so /a%7Eb and /a~b are now two
nodes, as they are to a crawler.
rurl_params is narrowed. Because the profile now pins a standard
selector, rurl rejects a conflicting override of the axes that selector
governs — case_handling and path_normalization. Those two are node
identity, so a caller who could move them could silently re-key the graph.
Every other knob still overrides as before.
Note rurl's profile = "seo" is deliberately not used and is not a
substitute: it bundles protocol_handling = "https", www_handling =
"strip", trailing_slash_handling = "strip" and index_page_handling =
"strip", each of which is a redirect class pagerankr models as an edge.
Folding those at canonicalization time turns the redirect into a self-loop.
canonical_profile() now follows "parse, do not fold". pagerankr has
redirects and canonical tags as first-class inputs, and those are the site's
own statement about which URLs are the same page. Canonicalization therefore
normalizes only what the standard makes the same resource and asserts
nothing about site configuration. Three knobs move to match:
port_handling = "strip_default" (was the rurl default "exclude"). A
non-default port is a different origin, so http://host:8080/a and
http://host/a are now two nodes. :80 on http and :443 on https are
redundant per spec and still fold.
host_encoding = "idna" (was "keep"). An IDN host and its punycode
form are the same request on the wire, so no redirect or canonical can
ever fold them -- the graph has to. http://münchen.de/a and
http://xn--mnchen-3ya.de/a are now one node, keyed on the punycode form.
Pass rurl_params = list(host_encoding = "keep") to restore the split.query_handling = "filter" (was the rurl default "drop"). A contentful
parameter is part of the resource: ?color=red and ?color=blue are two
pages, and no redirect or canonical need exist between them. Tracking
parameters are not, and are still dropped via the built-in rurl denylist
(utm_*, fbclid, gclid). This is the largest behavior change here --
on a faceted site, node counts will rise. The six query sub-options
(params_keep, params_drop, params_case_sensitive, sort_params,
empty_param_handling, decode_plus) are now key-shaping and so are
pinned explicitly too, all at the rurl defaults. sort_params = FALSE
matches the rurl key contract, under which query order and duplicates
are significant.The knobs that would fold a redirect class -- www_handling,
trailing_slash_handling, index_page_handling, protocol_handling -- were
already pinned to their non-folding values and are unchanged.
The declared rurl minimum is >= 3.0.0. Two corrections landed in this
cycle. Imports: previously required rurl (>= 2.1.0), a version that was
never released — rurl has no 2.1.0 tag, and its 2.x line begins at 2.2.0 —
so the stated minimum named an artifact no user could install; that was
corrected to >= 2.2.0, verified against rurl 2.2.0, 2.2.1 and 2.8.0 in
isolated libraries with byte-identical canonical keys across all three. The
floor then moved to >= 3.0.0, which is where the path-identity axis the
profile now depends on lives. Note the 2.2.0 floor had also become impossible to test
in practice: rurl inserted scheme_policy and scheme_acceptance
before url_standard rather than appending them, so a positional
url_standard argument silently binds to the wrong argument on eight functions across that
range.
New vignette, vignette("case-study"), works the two-crawl fixture
end to end. A worked example that scores the same site before and after an
internal-linking change on two lenses — the editorial (content-only) graph and
the full graph — and shows that a change which moves the top page from rank #1
to #38 on the first is invisible on the second (Pearson 0.9999). Every figure
is computed from inst/extdata/ at build time rather than quoted, so the
vignette doubles as a regression check on the fixture. It also documents the
three traps the comparison sets: scores do not sum to 1, so shares must be
taken against sum(pagerank); the two crawls have different node sets, so
absolute PageRank is not comparable across them; and pages must be matched on
the full URL, since external hosts share path strings with internal ones.
_pkgdown.yml gains an articles: index grouping all six vignettes.
inst/extdata/. Two Screaming
Frog exports of the same small site, taken either side of a deliberate
internal-linking intervention, are now available as package data:
system.file("extdata", "reviews-microsite-before", package = "pagerankr")
and its -after counterpart, each holding all_inlinks.csv and
internal_all.csv. Because the same site is measured twice, a change in
PageRank is attributable to the intervention rather than to a difference
between sites — which is what makes the pair usable for validating the
placement and boilerplate models. Both exports are trimmed to the columns
sf_contract() declares (18 and 24, down from Screaming Frog's 103), and
Link Path is retained deliberately as the boilerplate detector's signal.
The whole fixture is roughly 70 KB gzipped. The underlying site is
pseudonymous: hosts, paths, anchors, alt text and page free-text are replaced
rather than redacted, and inst/extdata/README.md documents exactly what is
synthetic and what is preserved verbatim.pagerank() surfaces a per-URL wasted_mass figure alongside page_state.
When indexability_df or status_df is supplied, the result now carries a
wasted_mass column next to page_state: the authority each page collected
and black-holed, i.e. its share of the shared waste sink's stationary mass.
page_state labels the "collects but cannot pass" class per row; wasted_mass
quantifies the amount, answering the other half of "a 404 page amassed X
PageRank and it ends up in a black hole." Because a waste-class page routes its
whole throughput through one edge to the absorbing sink, the value is
damping / (1 - damping) times the page's own reported score — larger than,
and deliberately distinct from, that score. Summed over the class it equals the
evaporated mass in the transition audit (mass$sink); a "live" page routes
nothing to the sink, so its wasted_mass is 0. Like page_state, the column
appears only when one of those inputs is present, mirroring how prior_weight
appears only with prior_df.Documented that page_state and simulate_changes()'s node_status are
distinct axes, not two names for one thing. page_state (live / noindex /
robots_blocked / response_dead) is a page's health/index state; node_status
(normal / new-target / removed-dead) is a node's role in a before/after
comparison. They are kept separate on purpose: new-target has no health
analog, and removed-dead is the single value bridging both axes (a node
removed because its proposed health state is a forced 404). The two @return
sections now cross-reference each other.
simulate_changes() gains a remove_urls verb for URL-level what-ifs.
Pass a character vector of URLs to model them returning 404: each keeps its
inbound links (other pages still point at it) but their authority now flows
into a dead page and evaporates to the shared waste sink, rather than
redistributing across the site via teleport (dangle) or self-amplifying via a
self-loop. The page's outbound links are dropped and the node stays in the
output flagged "removed-dead" in node_status, so its residual absorbed
mass is never misread as earned authority. This is the first consumer of the
status_df / waste-sink dead-class mechanism: remove_urls forces a 404
into the proposed model only, leaving the baseline live. A URL appearing in
both remove_urls and redirect_urls_df is an error (a page cannot be both a
301 and a 404); the manifest gains urls_removed. Available on the Screaming
Frog path via simulate_changes_screaming_frog(remove_urls = ...), where the
forced 404 composes on top of the bundle's real crawled status.
Page-state modeling — one faithful policy for pages that collect PageRank
but cannot pass it (epic PAGE-qzskzcfd). The three changes immediately
below — status_df, the unified waste sink, and prior_exclude_waste —
together replace three accidental, inconsistent behaviors for the noindex /
robots-blocked / 4xx-5xx class with a single model: authority flows in, is
booked, and stops. Before, the same class behaved three ways: a robots-blocked
page self-looped and self-amplified up to 8.3× (scoring 0.8875 trapped vs
0.1065 evaporating — holding 89% of a graph), 4xx/5xx pages were scored as
ordinary live vertices, and uniform teleport let 1,000 fake dead URLs
capture 95.2% of a site's PageRank for merely existing. The class now routes
to a shared waste sink, is excluded from the teleport vector, and is labeled
per-row in page_state so absorbed mass is never read as earned authority.
robots_blocked_action survives only as a presentation toggle
("show" / "vanish"); the self-looping "trap" is gone. Any graph
containing a class member changes score — accepted pre-CRAN (experimental,
single consumer). Reproducible experiment and full rationale:
notes/pagerank-behavior-field-notes.md §11.
pagerank() gains prior_exclude_waste to keep the "collects but cannot
pass" class out of the teleport vector. With the default TRUE, the noindex,
robots-blocked, and 4xx/5xx pages identified by indexability_df / status_df
receive zero teleport mass: they still collect the authority their inlinks
send, but are no longer paid the uniform teleport share for merely existing, so
a page can no longer manufacture authority by pointing at many dead ends (Page
& Brin 1998 criticize uniform teleport for "valuing pages simply because they
exist"). This is a first-class replacement for the error-prone prior_df +
prior_alpha workaround previously needed to zero teleport entries. Set
FALSE to restore uniform teleport over every page, matching
igraph::page_rank() for canonical comparisons. Scores change whenever a
non-empty class is present; graphs with no such class — and any run without
indexability_df/status_df — are numerically unchanged. The synthetic
evaporation and leak sinks are excluded from teleport regardless of this flag,
which is recorded in the transition audit as config$prior_exclude_waste. No
effect under reverse = TRUE, where the class inputs are already rejected.pagerank() gains a status_df input for HTTP response status. Supply a
url + status_code frame (column names configurable via status_url_col
and status_col) and pages that returned a 4xx or 5xx code are identified as
response-dead and counted in the transition audit (config$has_status and
dropped$n_status_dead). 4xx and 5xx are treated identically — a crawl is a
snapshot, and a transient 503 is indistinguishable from a permanent 404 at
crawl time (the same reason a 302 is folded exactly like a 301); 3xx
stays with redirects_df, and unparseable or sub-400 codes are treated as
live. pagerank_screaming_frog() now feeds status from the bundle's node
table automatically instead of discarding it, and the raw preset leaves it
off. Not supported with reverse = TRUE.robots_blocked_action values are renamed c("trap", "vanish") →
c("show", "vanish"), default "show" (the self-loop "trap" is gone).
Both values route the page's throughput to the sink; they differ only in
whether the page is shown ("show") or removed with its own mass booked
as hidden ("vanish").nofollow_action: a noindex page always routes
to the sink, and nofollow_action now governs only real rel=nofollow
edges. This changes noindex behavior only under nofollow_action "drop"
/ "keep"; the default "evaporate" is numerically unchanged.indexability_df or status_df is supplied, the result gains a
page_state column tagging each visible page "live", "noindex",
"robots_blocked", or "response_dead", so wasted mass is attributable
per URL (robots-blocked > response-dead > noindex when a page carries more
than one signal). It appears only with those inputs, mirroring how
prior_weight appears only with prior_df.simulate_changes() can now model URL-level what-ifs, not just edge-level
ones. The new redirect_urls_df argument (a two-column from/to frame)
models retiring a page behind a redirect: it strips the live source's own
outbound links before folding, so the target inherits the source's inbound
authority only — never its outlinks — and the source drops out of the proposed
set. A row for a source overrides any prior redirect for it (an earlier
row, or the baseline crawl's real 3xx), so "repoint A→B into A→C" is a single
override. Redirects pass authority through at 100% (the only per-hop loss is
the global damping every edge already incurs). This replaces the removed
add_redirects_df, which folded a live source without stripping its
outedges and so modeled a content move rather than a retire.on_unknown_target = c("warn", "error", "allow") controls what happens
when a redirect or link target is not yet a node in the graph (it may be a
legitimate new page); the default "warn" proceeds and models it as a new
node carrying inbound authority.node_status column ("normal" or
"new-target") plus proposed (the full proposed pagerank() result, with
its transition audit) and manifest (redirects applied/overridden, link
add/remove counts, unknown targets) attributes.simulate_changes_screaming_frog() applies the same what-if verbs to a
screaming_frog_bundle, reusing the same bundle → pagerank() adapter as
pagerank_screaming_frog() so placement, boilerplate, nofollow, and status
are preserved automatically. Both entry points share one changeset engine.pagerank() can now weight links by their reading order
within the page. Point the new position_col at a numeric per-source
position index — 1 for the first link, 2 for the second — and each edge's
weight is scaled by a reading-order decay. This is the orthogonal axis of the
edge-weighting model: where placement and boilerplate describe templatedness
and feed one graded axis combined by minimum, position describes reading
order and composes by multiplication, so an above-the-fold boilerplate
CTA (0.5 × 1.0) outranks a trailing organic link (1.0 × 0.2) with no
special-casing. The decay reuses the existing transform_weights() shapes:
position_transform is "zipf" (default, 1 / rank^position_alpha) or
"rank_linear", applied within each source page's choice set, and
position_floor (default 0.01) clamps the result strictly above zero so
compounding two axes can never reach an effective drop. Off unless
position_col is supplied, and crawler-neutral: any crawler that can report a
link's order can drive it. Recorded separately in the transition audit's
config$position, so an edge's weight can be explained as region times
reading order.screaming_frog_links() now materializes that index at ingest. The edge
table gains a position_index column carrying each link's reading-order rank
among its source page's content links — computed from document order for an
All Outlinks export, and left NA for All Inlinks, whose row order is
destination-alphabetical rather than document order. pagerank_screaming_frog()
therefore drives the axis with position_col = "position_index"; it stays
opt-in, so pass that argument to switch it on.pagerank_screaming_frog() gains a preset argument, and preset = "raw"
now actually produces the as-crawled graph. Previously the wrapper fed
pagerank() the bundle's declared canonical, redirect and indexability
tables unconditionally, so a preset — which sets policy, never data — could
not unset them, and preset = "raw" still folded canonicals/redirects and
trapped robots-blocked pages. The "raw" view now switches all three
declared tables off at the wrapper level. An explicit apply_canonicals or
apply_redirects still overrides the "raw" default for that table; the
import audit records apply_indexability alongside the existing two flags.pagerank() can now discount repetitive template
links that sit in the main content. Point the new container_col at a
column identifying each link's source-side component and every edge is scored
by container-conditioned recurrence: of the pages that component appears on,
the share on which it points at this same target. A recycled call-to-action
that always links the same place scores near 1 and is discounted; a
related-posts module that links somewhere different on every page scores near
0 and keeps full weight — even though both recur identically. This catches
what placement never can, because bylines, compliance links and recycled CTAs
are structurally content. Tuned by boilerplate_threshold (default 0.5, the
ratio at which an edge is classified), min_container_pages (default 10,
an evidence floor) and boilerplate_weight (default 0.5, the discount
applied once classified). The last two constants are unrelated quantities
that happen to share a value. Off unless container_col is supplied, and
crawler-neutral: any crawler that can identify a link's component can drive it.vignette("boilerplate") documents the detector: the
container-conditioned metric and the direction of its ratio, the four
arguments and why boilerplate_threshold and boilerplate_weight are
unrelated despite sharing a default, how region weighting and recurrence
compose as one graded axis rather than two multiplied ones, and which kinds of
page the detector characteristically discounts.sf_container_from_path() derives that component identity from a
Screaming Frog Link Path, and screaming_frog_links() now carries a
container column on both the observation and edge tables (counted in the
link diagnostics as container_rows). The rule is: strip numeric [n]
predicates, keep [@class='…'], then drop the trailing <a> step. This
cuts the opposite way from sf_region_from_path(), which strips class
predicates so a div[@class='site-footer'] is not read as a <footer>; the
two answer different questions ("which region is this" versus "is this the
same component") and the inconsistency is deliberate.
Rows with no Link Path get NA and stay unscored — unlike placement there
is no Link Position fallback, because a region label cannot manufacture a
component identity.pagerank_screaming_frog() does not enable the detector on its own. Pass
container_col = "container" to opt in, exactly as a non-Screaming-Frog
caller would; supplying the column is what switches detection on, so passing
it automatically would change the default view.0.1, repetitive
in-content 0.5, unique in-content 1. The transition audit still records
both factor sets separately in config$placement and config$boilerplate,
since the resulting weight alone cannot say which detector produced it.The internal synthetic weight column is renamed .__pr_placement_weight__ to
.__pr_edge_weight__, since both weighting axes now write it. It surfaces
in attr(result, "transition_audit")$config$weight_col.
Two new presets complete the set: "reversed" and "content".
pr_preset() now registers raw · declared · reversed · content.
"reversed" is the feeder view (reverse = TRUE); it is a no-op rather than
an error under topic_feeder_pagerank(), which reverses the graph itself.
"content" carries the placement recipe c(content = 1, nav = 0.1,
header = 0.1, footer = 0.1, aside = 0.1), so links found in site chrome are
discounted to a tenth of an in-content link — downweighted, never dropped.
All five regions are named on purpose: unnamed placements keep weight 1, so a
partial recipe would leave footer and aside outweighing nav tenfold. Because
a preset sets policy and never data, "content" still needs you to supply
placement_col; pagerank_screaming_frog() supplies it from the bundle.
vignette("presets") documenting every preset's full expansion, the
precedence rule, and how provenance is recorded in the transition audit.placement_weights or accepted_placements without
placement_col now names the preset responsible when a preset is what set
them, instead of reporting an argument the caller never typed.pagerank() gains
placement_col, accepted_placements, and placement_weights: point
placement_col at a column holding the page region each link sits in and
the region can filter or weight edges directly, e.g.
pagerank(edges, placement_col = "region", placement_weights = c(content = 1,
nav = 0.1, header = 0.1, footer = 0.1, aside = 0.1)). Placement is not a
Screaming Frog concept — any crawler that reports link regions can drive it,
with a per-crawler adapter (sf_normalize_position() for Screaming Frog)
mapping vendor labels onto the shared vocabulary. All three arguments default
to NULL, so nothing changes for callers that do not use them, and because
they are pagerank() formals they compose with preset and are inherited by
every wrapper that forwards ....placement is now derived from the DOM path
rather than from Link Position, via a new exported sf_region_from_path().
Link Position loses the enclosing region whenever a <nav> is nested inside
one: on a site whose footer is marked up as footer > nav > a, Screaming Frog
reports every footer link as Navigation and emits no Footer bucket at all,
so footer is not merely mislabeled but unreachable — a user wanting footer
at 0.05 and nav at 0.2 had no way to express it. Other sites do emit
Footer, so the vocabulary silently varied with the site's markup. The region
is the outermost layout container on the path (header, footer, aside,
else content), and nav applies only to a <nav> that is not inside one of
those; Link Position remains the fallback for rows with no path, counted in
the link diagnostics as placement_from_position_rows. Expect footer and
header navigation to reclassify out of nav — this changes which edges
accepted_placements selects, though not the usual recipe, which weights all
three alike."sidebar" is renamed "aside", matching
both Screaming Frog's own label and the HTML element; "sidebar" is a layout
word rather than a semantic one. sf_normalize_position() now returns
"aside" where it previously returned "sidebar", and "sidebar" is no
longer accepted in accepted_placements / placement_weights.pagerank_screaming_frog() keeps its signature but is now a thin adapter:
accepted_placements and placement_weights are forwarded to pagerank(),
which owns the filtering and weighting. Two consequences for the
"screaming_frog_import" attribute: scoring$scored_edge_rows is renamed
scoring$edge_rows_to_pagerank (it counts rows after the wrapper-owned link
origin filter, since placement filtering has moved down a layer), and
scoring$effective_weight_col is dropped — the transition audit already
carries it, alongside a new config$placement entry recording the placement
column, filter, weights, and the number of rows the filter dropped.New preset argument on pagerank() and a new exported pr_preset()
constructor: named argument bundles for recurring views of a link graph,
so a view is a one-liner instead of a hand-assembled argument list. Two
presets ship so far: "raw" (the graph exactly as crawled -- self loops and
isolates kept, rel=nofollow ignored, no relabeling onto uncrawled fold
targets) and "declared" (honor the signals the site declares -- nofollow
evaporates, declared canonical and redirect targets are followed,
robots-blocked pages keep the authority they collect, self loops and
isolates dropped). "declared" is a pure pin of the package defaults:
it changes nothing about how pagerank() behaves, it states the default
view so a run can record which view was intended and stay pinned to that
bundle if a default later moves. preset
accepts a preset name, a pr_preset() result, or any hand-rolled named list
of pagerank() arguments; bundles are plain named lists, so they are
inspectable and spliceable via do.call(). Precedence is explicit
argument > preset > base default -- a preset value is applied only to
arguments the caller did not name -- and this holds through the wrappers
that forward ... to pagerank() (trustrank(),
topic_sensitive_pagerank(), topic_feeder_pagerank(),
pagerank_screaming_frog()), with the boundary that arguments a wrapper
sets itself stay wrapper-owned. Presets are strictly opt-in; no default
behavior changes.
The transition_audit attached to a pagerank() result now records which
preset produced it, in config$preset: the preset name for a registered
preset (passed by name or as a pr_preset() result), "custom" for a
hand-rolled bundle, and NULL when no preset was used. Two runs that expand
to the same configuration are no longer indistinguishable in the audit --
which named view was asked for is itself a provenance fact, and it is the
only durable record of intent for a pin like "declared". print() shows a
Preset: line only when a preset was used, so default output is unchanged.
Breaking: the six dot-prefixed Screaming Frog helpers are renamed without
their leading dot and are now documented public API: .sf_contract(),
.sf_read_input(), .sf_parse_follow(), .sf_rel_nofollow(),
.sf_normalize_position(), and .sf_graph_eligible() become
sf_contract(), sf_read_input(), sf_parse_follow(),
sf_rel_nofollow(), sf_normalize_position(), and sf_graph_eligible().
A leading dot conventionally signals "internal", but these were already
exported -- and exported with @noRd, so they had no help pages at all.
They are intended for downstream Screaming Frog workflows, so they now carry
full documentation (parameters, return values, examples) and are grouped as
@family Screaming Frog toolkit with their own pkgdown reference section.
Behavior and signatures are unchanged. The many genuinely internal .sf_*
helpers keep their leading dot and remain unexported.
Breaking: resolve_urls() is renamed to resolve_redirect_urls(). The
function originally resolved redirects and canonicals together; once that
was split apart (mixing the two is lossy) the old name no longer described
what it does. The new name states the operation and restores symmetry with
the rest of the resolve_* family, which pairs an edge-list form with a
URL-vector form: resolve_redirects() / resolve_redirect_urls() alongside
the existing resolve_canonicals() / resolve_canonical_urls(). Behavior,
arguments, and return value are unchanged.
The resolve_* naming convention is now documented and enforced by grouping:
functions taking a character vector of URLs are tagged
@family URL-vector resolvers (resolve_redirect_urls(),
resolve_canonical_urls(), resolve_folded_urls()) and functions taking an
edge_list_df are tagged @family edge-list resolvers
(resolve_redirects(), resolve_canonicals(), resolve_links()), so each
help page cross-links its input-type peers.
The weight/teleport transform vocabulary now uses one canonical option order
("none", "log", "percentile", "minmax", "zipf", "rank_linear") at
every call site -- transform_weights(), transform_edge_weights(),
pagerank(prior_transform=), align_prior_to_vertices(), and
ga4_entrance_teleport(). No defaults change: transform_edge_weights()
still defaults to "zipf" (now declared explicitly rather than by option
order) and every other site still defaults to "none".
Documented that canonical_profile() deliberately leaves rurl's
component-dropping knobs unpinned (query_handling, port_handling, and the
url_standard selector added in rurl 2.2.0) -- they have no effect on the
scheme+host+path node key at their defaults. Added a behavioral guard in
test-canonicalization.R asserting a canonical key drops the port, query, and
fragment, so a future rurl default flip on an unpinned knob is caught here
rather than silently changing node identity. No node keys change; verified
against rurl 2.2.0.
canonical_profile() now pins path_normalization = "dot_segments" and
path_encoding = "decode" (previously "none" / "keep"). rurl 2.1.0
silently redefined those two default values to keep the path verbatim, which
changed node keys for any URL with dot-segments (/a/../b) or percent-encoding
(/%41, %20) and desynced the pagerankr <-> semantic node join. Pinning the
explicit values restores the original committed key (path percent-decoded,
dot-segments removed) and keeps node identities stable across the rurl
upgrade. The semantic sibling pins the identical profile (changed together).
clean_url_columns() now preserves tokens that rurl cannot parse as a URL
(e.g. a dotless bare label such as "A") as their raw value instead of
turning them into NA. Newer rurl (>= 2.1.0) normalizes such dotless tokens
to NA; combined with the rurl floor bump in the follow-up,
this had silently collapsed non-URL node identities to NA — they were then
dropped by get_unique_edges(), so [pagerank()] returned an empty result for
any graph built from bare labels. Unparseable-but-present tokens are now kept
as opaque nodes (only genuinely missing NA inputs stay NA), mirroring
.apply_fold_map()'s leave-unmapped-values-untouched contract.
filter_links_by_domain()'s encoding-independent registrable-domain matching
now reads rurl's new domain_ascii column (rurl >= 2.1.0) instead of a
separate IDNA-forced parse. .build_url_maps() parses each unique URL once
(host + domain_ascii) rather than twice, and the .domain_profile()
forced-idna helper is gone. Behavior is unchanged — münchen.de and
xn--mnchen-3ya.de still fold to one key under every host_encoding.
pagerank_screaming_frog() gains apply_canonicals and apply_redirects
toggles (both TRUE by default, preserving current behavior). Setting either
to FALSE skips folding the bundle's canonical / redirect signals into
[pagerank()] (passes canonicals_df / redirects_df as NULL), giving a
supported as-crawled run that keeps the crawled node identities — the escape
hatch for crawls whose canonicals point off the crawled domain (mirror /
staging hosts) and would otherwise relabel crawled pages onto uncrawled
targets. The reserved-argument guard still blocks the raw canonicals_df /
redirects_df pagerank arguments. screaming_frog_bundle() now reports an
off-domain canonical count (counts$canonicals_off_domain, surfaced in
summary()/print()), reusing the existing absent-target classification, and
the wrapper exposes it on the screaming_frog_import audit so the
mirror-staging scenario is visible at import and scoring time.
pagerank() now detects fold-target collisions: when a canonical/redirect
relabels a crawled page's node onto an uncrawled URL that is ALSO
independently referenced as a genuine link endpoint, the two silently merge
into one vertex and the crawled page absorbs the inbound link equity of that
uncrawled URL.
pagerank() emits a warning() naming the merged URL(s) and records them in
the fold section of the transition_audit object under a new collisions
field (a data frame of target / n_independent_refs / source, or NULL
when none). The crawl's known-URL set (indexability_df) is used to tell an
uncrawled fold target from a genuinely crawled leaf page, so the diagnostic is
only computed when an indexability_df is supplied.
pagerank() now warns when a keep_domains / exclude_domains /
keep_hosts / exclude_hosts value matched the crawled input but no node
after folding — i.e. an out-of-scope canonical/redirect rewrote the crawled
domain/host away before filtering (which runs after folding). The warning
names the folded-away value(s) and points at the fold as the cause. The
fold-then-filter ordering is now documented explicitly in the pagerank()
and filter_links_by_domain() docs; to scope the crawled input, filter with
filter_links_by_domain() before calling pagerank().
out_of_scope_fold gains a third policy, "leak": a crawled page whose
canonical/redirect folds out of scope is treated like an external redirect —
its inbound equity is routed onto a dedicated leak sink and evaporates out of
the measured graph (its outbound edges are dropped), so it does not rank and
its equity is not credited to any surviving page. The evaporated equity is
reported as a new leaked term in the transition_audit mass accounting,
which now decomposes as reported + sink + leaked + hidden = total (= 1);
leaked is 0 for "relabel"/"keep" runs so their totals are unchanged.
The fold audit section reports policy == "leak".
pagerank() gains an out_of_scope_fold argument ("relabel" default, or
"keep") governing composed fold-map entries whose target is not itself a
crawled node. "relabel" preserves current behavior (fold crawled sources
onto uncrawled canonical/redirect targets); "keep" drops those out-of-scope
entries before folding so crawled pages retain their as-crawled identity
rather than being relabeled to phantom vertices (the same filtered map folds
the TIPR prior). Regardless of policy, the count and list of out-of-scope
folds (source, target, signal) are recorded in a new fold section of the
transition_audit object.
Code-quality pass: anyNA() replaces any(is.na()), !all(x) replaces
any(!x), nested ifelse() replaced with vectorized assignment,
expect_gt()/expect_lt()/expect_length()/expect_null() adopted where
applicable, and redundant c() wrappers around single-string aliases removed.
pagerank() and compute_pagerank() gain convergence controls and reporting.
The new algo argument selects the igraph::page_rank() back-end
("prpack", the fast exact default, or "arpack", the iterative solver), and
the friendly eps / niter aliases re-introduce the L1 tolerance and maximum
iteration count that modern igraph dropped, mapping onto the ARPACK
options$tol / options$maxiter; supplying either transparently switches to
ARPACK. Every non-empty result now carries a "convergence" attribute (a
pagerank_convergence object) reporting the solver, iteration count (when the
solver exposes it), and a solver-independent post-hoc L1 residual
||Gx - x||_1 of the returned vector — a genuine quality check comparable
across both back-ends. Docs cover the damping/iteration-count rule of thumb
log10(eps) / log10(damping).
topic_feeder_pagerank() answers the inverse of
topic_sensitive_pagerank(): not "which page is most authoritative for this
cluster" but "which pages feed / power this cluster" — the internal hubs
whose outlinks point into the target pages. It seeds the teleport prior on the
cluster and runs pagerank() on the transposed graph (reverse = TRUE), so
mass walks backward along links and accumulates on the feeders, attenuating
with link distance. This is the reverse-graph sibling of Topic-Sensitive
PageRank and the cluster-biased counterpart to the global inverse PageRank
(pagerank(reverse = TRUE)); the feeders are the high-pagerank rows with
prior_weight == 0 (cluster pages carry teleport mass directly). Pure
orchestration over the existing TIPR personalization path on the reversed
graph — no new solver. New topic_feeder_pagerank vignette walks through the
AI-Agent-cluster use case and contrasts it with the forward authority view and
with HITS hubs.salsa() and compute_salsa() add Lempel & Moran's (2001) SALSA hub and
authority scores: a stochastic variant of HITS that runs the
mutual-reinforcement step as PageRank-style random walks on the bipartite
hub/authority graph, so the scores are stationary distributions rather than
dominant eigenvectors. Computed over the same cleaned,
redirect/canonical-folded, domain-filtered, deduplicated link graph as
pagerank(), so hub, authority, and PageRank share node identities. Uses the
degree-based closed form (Proposition 6) — no eigenvector iteration — with the
required weakly-connected-component mass-weighting correction so
cross-component scores stay comparable on crawls with orphan clusters. Each
side sums to 1; coverage differs from PageRank by design (hub is NA for
pure sinks, authority is NA for pure sources). v1 is unweighted; a
weighted extension is deferred. Documented as a site-graph adaptation of the
original focused-subgraph algorithm.trustrank() and the shared seed_prior() builder add TrustRank-style
seed-biased PageRank (Gyöngyi, Garcia-Molina & Pedersen, 2004): personalized
PageRank whose teleport vector is concentrated on a set of trusted seed pages,
so trust flows outward from the seeds and attenuates with distance (the
damping factor is the attenuation). seed_prior() builds a prior_df from
a seed set (character vector or weighted data.frame; equal weights reproduce
the original uniform seed distribution) and is orientation-agnostic — the same
builder feeds topic_feeder_pagerank() on the reversed graph. trustrank()
is the one-call wrapper that builds the seed prior and runs pagerank() with
it. Pure
orchestration over the existing TIPR personalization path — no new solver;
seed selection is the caller's (it is seed-biased PageRank, not a spam
classifier). New trustrank vignette walks through a worked example.topic_sensitive_pagerank() computes per-topic PageRank by running the
existing pagerank() engine once per topic with a teleport prior biased
toward each topic's seed cluster, then blends the per-topic scores into a
single ranking. This is Haveliwala's (2002) Topic-Sensitive PageRank adapted
to a single site: each "topic" is a content cluster (e.g. the pricing or
AI-Agent area) given as a character vector of seed URLs or a weighted
data.frame. Returns one score column per topic plus a weight-normalized
blended column, with the per-topic transition_audit objects attached.
Pure orchestration over the TIPR personalization path — no new solver, and
topic membership is supplied by the caller, not inferred.smooth_transitions() shrinks sparse empirical page-transition shares
(e.g. from ga4_page_transitions()) toward the crawl-graph link structure,
so no valid crawled link is ever assigned exactly zero probability. Uses a
per-source Dirichlet/pseudocount shrinkage weight lambda_i = n_i / (n_i + k)
that increases with the source page's sample size, with min_support
fallback to the prior, optional weighted priors, and an origin diagnostic
(both / empirical_only / structural_only). Time decay and
device/template/channel segmentation are handled upstream by shaping the
count input.hits() and compute_hits() add Kleinberg's HITS hub and authority
scores, computed with igraph::hits_scores() over the same cleaned,
redirect/canonical-folded, domain-filtered, deduplicated link graph as
pagerank(), so hub, authority, and PageRank share node identities. Docs
cover the matrix formulation (authority = dominant eigenvector of A^T A,
hub = dominant eigenvector of A A^T) and the whole-graph caveat: unlike
Kleinberg's query-focused base set, these are site-wide structural
centralities.pagerank() now has an explicit duplicate_edge_policy for repeated
from -> to rows after URL folding and filtering. The default "collapse"
preserves the standard binary/destination-level PageRank convention and
previous results; opt-in "aggregate" sums duplicate numeric weights with
aggregate_edges() semantics; opt-in "count_instances" models a
link-slot surfer where repeated links increase transition probability and
records instance-count details in the transition audit.pagerank_screaming_frog() scores a screaming_frog_bundle() through
the existing pagerank() pipeline, feeding only graph-eligible hyperlink
edges while attaching Screaming Frog import diagnostics beside the transition
audit. Placement/origin filtering and placement-derived weighting are
explicit opt-ins.screaming_frog_bundle() composes Internal: All and All Inlinks/Outlinks
adapters into the stable crawl handoff object with raw observations, graph
edges, node signals, cross-table reconciliation diagnostics, provenance, and
concise print/summary methods.resolve_canonicals(), resolve_canonical_urls(), and
resolve_folded_urls() expose the existing fold-map engine for rel=canonical
and redirect+canonical URL folding without duplicating resolver logic.pagerank(): noindex
pages may receive authority but their outlinks are treated as nofollow for
propagation within the indexed graph. The docs now distinguish
slot-consuming "evaporate", slot-removing "drop", and normally followed
"keep" without attributing this package model to Google.screaming_frog_links() imports All Inlinks and All Outlinks
with identical Source-to-Destination orientation, preserving raw duplicate
observations while deriving explicit Hyperlink-only graph edges with
nofollow, placement, origin, endpoint, and exclusion diagnostics.screaming_frog_internal() imports UTF-8/BOM Internal: All exports
with alias-insensitive schema detection and selective file reads. It returns
deterministic node, redirect, canonical, and indexability tables while
preserving raw URLs and reporting missing, duplicate, invalid, and ignored
input facts.pagerank() now attaches a transition_audit provenance object to its result
as attr(result, "transition_audit") (backward-compatible): row/edge counts,
behavioral-weight coverage, normalization total, dropped data (NA / dedup /
self-loop rows, unmatched prior URLs), robots-blocked count, and the model
configuration used. Has a print method.transition_audit object's mass field now decomposes the page-mass
deficit precisely into reported (visible page mass), sink (evaporated
nofollow-sink mass), hidden (robots-blocked mass), and total (= 1 by
construction) — replacing undifferentiated "leakage" language with precise
evaporated/hidden accounting.aggregate_edges(): loss-aware post-fold edge aggregation with explicit
per-column semantics (sum counts, boolean conflict policy any/all/
majority/error, preserve_cols list-columns for placement features).transform_edge_weights(): per-source grouped weight transforms reusing
transform_weights() methods, emitting a per-source transition_probability
that sums to 1 within each by group.ga4_page_transitions(): consecutive-page-view transition counts from a
GA4 BigQuery export with a deterministic session/event ordering contract
(timestamp + batch tie-breaks). A transition signal, not a link-click signal.New ga4_entrance_teleport(): entrance/landing-page counts as a teleport
(reset) vector reusing the prior_df / align_prior_to_vertices() machinery;
documented as a proxy, distinct from the backlink-authority prior.
Initial CI and lint baseline.
The agent instructions no longer import FP_AGENTS.md, the file the fp
tracker generates, which is deleted. AGENTS.md points at the house
agent-workflow and fp skills for the git workflow (SEOR-ipwcbcov).
The two dependency audits run on a dedicated schedule, and the OSS Index
one now runs at all. osv-audit fired on every pipeline schedule, on
main pushes that touched DESCRIPTION or its test, and as a manual job on
every pipeline; it now fires only on a schedule that sets
SCHEDULE_KIND=dependency-audit, or by hand. A new security-audit job runs
test-security.R under the same rules with OSSINDEX_AUDIT_REQUIRED=true,
so missing OSS Index credentials fail the job instead of letting it pass
having audited nothing (SEOR-fftbjnpl).
SECURITY-INSIGHTS.yml describes the project as it is hosted now. It
still cited GitHub issues, releases, Actions workflows that no longer exist,
Codecov, a github.io site and GitHub private vulnerability reporting. It now
cites the GitLab repository and tracker, .gitlab-ci.yml, the gitlab.io
site and the channels SECURITY.md names (SEOR-wmtfrsjq).
.bestpractices.json now names GitLab and describes today's CI, and
scripts/bestpractices-url.py, copied from the seor repository, pushes
it. The OpenSSF self-assessment still cited GitHub Issues, GitHub private
vulnerability reporting, the deleted .github/workflows/ files, Codecov and
the github.io site. Every justification now points at the GitLab repository,
work items, pipelines and the pkgdown site DESCRIPTION declares, and names
GitHub only as the read-only mirror. Four answers moved to what the repository
actually does: static_analysis_common_vulnerabilities, dynamic_analysis
and tests_documented_added are Unmet, and dynamic_analysis_fixed is N/A.
The OpenSSF Best Practices site does not import the file from a GitLab
repository, so the script turns it into edit links the owner opens and saves,
and --check compares the live entry with the file. A pre-push hook runs its
offline self-test when the script changes (SEOR-grrcptww).
The OSS Index audit judges each advisory by disposition, not by "the scan
is empty". tests/testthat/test-security.R now reads an allow-list in the
new tests/testthat/helper-security.R: a reported advisory not on the list
fails, a listed advisory no longer reported fails, and a row past its review
date or version warns. The list is empty because the 24-package hard
dependency closure reports zero advisories. A validator checks every row
against fixtures, an audit that resolves no packages fails, and under
OSSINDEX_AUDIT_REQUIRED=true a missing oysteR or missing credentials is a
failure rather than a skip, so a future credentialed audit job cannot go
green having audited nothing (SEOR-fftbjnpl).
The pre-push verify gate fails when a checker is missing, instead of
skipping the check and exiting 0. Four checks -- citation, the
BugReports: split, spelling and R CMD check -- were guarded by "is the
tool installed?" tests that printed a warning line on a miss and let the gate
pass. On a feature
branch this hook is the only gate that runs anywhere, so its most degraded
form still reported success. A missing checker is now a failed gate. The
opt-in bypasses (SKIP_VERIFY, SKIP_RCMDCHECK, SKIP_SPELLING) are
unchanged and report as SKIP (opt-in) rather than as passes. The gate also
runs every check before reporting and ends with a verdict list naming each
one, so "the gate passed" is a claim about a named set (SEOR-dzrisdmi).
scripts/check-bugreports.py now runs in CI. It had zero references in
.gitlab-ci.yml, so the newest gate in the fleet ran on a correctly
provisioned laptop and nowhere else. It joins the existing citation-version
job, which already runs on a python image (SEOR-dzrisdmi).
scripts/gates.R takes optional gate names and a --no-summary flag, so the
pre-push hook runs the same news-version and codemeta implementations CI
runs instead of a second copy of the rules in bash (SEOR-dzrisdmi).
DESCRIPTION's URL: now lists the package's r-universe page. r-universe
records this repository's upstream owner as gitlab-bart-turczynski because
it is hosted on GitLab, which does not match the bart-turczynski universe,
so the package was built and served but hidden from r-universe search. The
URL claims it (SEOR-zfamoutf).
The OSS Index dependency audit in tests/testthat/test-security.R scopes to
hard dependencies (Depends + Imports) instead of the Suggests tree.
oysteR::expect_secure() audits Suggests too, which pulled in oysteR's own
recursive dependencies -- curl among them -- and failed the pre-push gate on
a vulnerability in the auditor rather than in anything pagerankr ships. Scoped
to hard dependencies the audit covers 23 packages and is clean; the old scope
covered 126 (PAGE-aqnbdkov).
CI adds CRAN behind the image's Posit Package Manager repo. p3m lags CRAN by
up to a day for a new release -- on the day rurl 3.0.1 was published, the
2026-09-09 p3m snapshot and p3m latest both still served 1.2.0 -- so
Imports: rurl (>= 3.0.1) was unresolvable at any pin and every
dependency-resolving job failed. p3m stays first, so packages still arrive as
prebuilt binaries; CRAN only supplies what p3m has not synced yet.
The rurl-floor CI job resolves the floor from CRAN (current or archived)
instead of reading the source forge out of Remotes: and installing a git
tag. It now runs on every merge request rather than manually, because it can
pass. Its GitHub-hosted twin, which had no live runner and hard-failed on the
removed Remotes: field, was deleted; the GitLab job supersedes it.
The pre-push hook's non-gating rurl skew notice compares the installed rurl
against CRAN rather than against the Remotes: target. The skew it warns
about is not hypothetical: a local 3.0.0 build sat in front of CRAN 3.0.1
here and hid a new get_clean_url argument from the suite.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.