Changes in version 0.1.1.9000 Breaking changes - pagerankr now requires R >= 4.1.0 (was R >= 4.0.0), as pslr, which it needs through rurl, does (SEOR-wurguiqa). Bug fixes - Case-insensitive matching of Screaming Frog headers, XPath regions, placement labels and hosts no longer depends on the locale (PAGE-vzetnwuu). - Help pages render their Markdown formatting instead of showing it as literal text (PAGE-iyfuhqyz). Changes in version 0.1.1 (2026-10-01) - 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). Changes in version 0.1.0 (2026-09-28) - 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. - Percent-encoded dot segments resolve again. 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. - A two-crawl case-study fixture ships in 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. - Unified waste-sink mechanism for the "collects PageRank but cannot pass it" class. noindex, robots-blocked, and response-dead (4xx/5xx) pages now share one mechanism: each loses its outgoing edges and gains exactly one edge to a shared internal waste sink, so it absorbs the authority its inlinks send but passes none back into the graph. This removes the old robots-blocked self-loop, which was an absorbing rank sink that compounded inbound authority every iteration (a measured 8.3× inflation); robots-blocked scores change accordingly. A no-outlink 404 now gets the one sink edge that stops it from dangling and recycling its authority to every page via teleport. - 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"). - noindex is decoupled from 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. - When 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. - New 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. - The comparison output gains a 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. - New 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. - Positional decay: 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. - Boilerplate detection: 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. - New 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. - New 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 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