Changelog
Source:NEWS.md
pagerankr (development version)
The declared
rurlminimum is corrected to>= 2.2.0.Imports:previously requiredrurl (>= 2.1.0), a version that was never released —rurlhas no2.1.0tag, and its 2.x line begins at 2.2.0 — so the stated minimum named an artifact no user could install. The effective minimum was already 2.2.0 in practice; the constraint now says so. Verified by running the test suite againstrurl2.2.0, 2.2.1 and 2.8.0 in isolated libraries: all three pass identically, and canonical URL keys are byte-identical across them, so this corrects the declaration without changing behavior or node identity.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 frominst/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 againstsum(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.ymlgains anarticles: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-aftercounterpart, each holdingall_inlinks.csvandinternal_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 columnssf_contract()declares (18 and 24, down from Screaming Frog’s 103), andLink Pathis 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, andinst/extdata/README.mddocuments exactly what is synthetic and what is preserved verbatim.pagerank()surfaces a per-URLwasted_massfigure alongsidepage_state. Whenindexability_dforstatus_dfis supplied, the result now carries awasted_masscolumn next topage_state: the authority each page collected and black-holed, i.e. its share of the shared waste sink’s stationary mass.page_statelabels the “collects but cannot pass” class per row;wasted_massquantifies 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 isdamping / (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 itswasted_massis0. Likepage_state, the column appears only when one of those inputs is present, mirroring howprior_weightappears only withprior_df.Documented that
page_stateandsimulate_changes()’snode_statusare 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-targethas no health analogue, andremoved-deadis the single value bridging both axes (a node removed because its proposed health state is a forced 404). The two@returnsections now cross-reference each other.simulate_changes()gains aremove_urlsverb 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"innode_status, so its residual absorbed mass is never misread as earned authority. This is the first consumer of thestatus_df/ waste-sink dead-class mechanism:remove_urlsforces a404into the proposed model only, leaving the baseline live. A URL appearing in bothremove_urlsandredirect_urls_dfis an error (a page cannot be both a 301 and a 404); the manifest gainsurls_removed. Available on the Screaming Frog path viasimulate_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, andprior_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 inpage_stateso absorbed mass is never read as earned authority.robots_blocked_actionsurvives 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()gainsprior_exclude_wasteto keep the “collects but cannot pass” class out of the teleport vector. With the defaultTRUE, the noindex, robots-blocked, and 4xx/5xx pages identified byindexability_df/status_dfreceive 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-proneprior_df+prior_alphaworkaround previously needed to zero teleport entries. SetFALSEto restore uniform teleport over every page, matchingigraph::page_rank()for canonical comparisons. Scores change whenever a non-empty class is present; graphs with no such class — and any run withoutindexability_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 asconfig$prior_exclude_waste. No effect underreverse = TRUE, where the class inputs are already rejected.pagerank()gains astatus_dfinput for HTTP response status. Supply aurl+status_codeframe (column names configurable viastatus_url_colandstatus_col) and pages that returned a 4xx or 5xx code are identified as response-dead and counted in the transition audit (config$has_statusanddropped$n_status_dead). 4xx and 5xx are treated identically — a crawl is a snapshot, and a transient503is indistinguishable from a permanent404at crawl time (the same reason a302is folded exactly like a301);3xxstays withredirects_df, and unparseable or sub-400codes are treated as live.pagerank_screaming_frog()now feeds status from the bundle’s node table automatically instead of discarding it, and therawpreset leaves it off. Not supported withreverse = 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_actionvalues are renamedc("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, andnofollow_actionnow governs only realrel=nofollowedges. This changes noindex behavior only undernofollow_action"drop"/"keep"; the default"evaporate"is numerically unchanged. - When
indexability_dforstatus_dfis supplied, the result gains apage_statecolumn 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 howprior_weightappears only withprior_df.
-
simulate_changes()can now model URL-level what-ifs, not just edge-level ones. The newredirect_urls_dfargument (a two-columnfrom/toframe) 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 removedadd_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_statuscolumn ("normal"or"new-target") plusproposed(the full proposedpagerank()result, with its transition audit) andmanifest(redirects applied/overridden, link add/remove counts, unknown targets) attributes.New
simulate_changes_screaming_frog()applies the same what-if verbs to ascreaming_frog_bundle, reusing the same bundle →pagerank()adapter aspagerank_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 newposition_colat a numeric per-source position index —1for the first link,2for 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 existingtransform_weights()shapes:position_transformis"zipf"(default,1 / rank^position_alpha) or"rank_linear", applied within each source page’s choice set, andposition_floor(default0.01) clamps the result strictly above zero so compounding two axes can never reach an effective drop. Off unlessposition_colis supplied, and crawler-neutral: any crawler that can report a link’s order can drive it. Recorded separately in the transition audit’sconfig$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 aposition_indexcolumn carrying each link’s reading-order rank among its source page’s content links — computed from document order for an All Outlinks export, and leftNAfor All Inlinks, whose row order is destination-alphabetical rather than document order.pagerank_screaming_frog()therefore drives the axis withposition_col = "position_index"; it stays opt-in, so pass that argument to switch it on.pagerank_screaming_frog()gains apresetargument, andpreset = "raw"now actually produces the as-crawled graph. Previously the wrapper fedpagerank()the bundle’s declared canonical, redirect and indexability tables unconditionally, so a preset — which sets policy, never data — could not unset them, andpreset = "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 explicitapply_canonicalsorapply_redirectsstill overrides the"raw"default for that table; the import audit recordsapply_indexabilityalongside the existing two flags.Boilerplate detection:
pagerank()can now discount repetitive template links that sit in the main content. Point the newcontainer_colat 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 near1and is discounted; a related-posts module that links somewhere different on every page scores near0and 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 byboilerplate_threshold(default0.5, the ratio at which an edge is classified),min_container_pages(default10, an evidence floor) andboilerplate_weight(default0.5, the discount applied once classified). The last two constants are unrelated quantities that happen to share a value. Off unlesscontainer_colis 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 whyboilerplate_thresholdandboilerplate_weightare 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 FrogLink Path, andscreaming_frog_links()now carries acontainercolumn on both the observation and edge tables (counted in the link diagnostics ascontainer_rows). The rule is: strip numeric[n]predicates, keep[@class='…'], then drop the trailing<a>step. This cuts the opposite way fromsf_region_from_path(), which strips class predicates so adiv[@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 noLink PathgetNAand stay unscored — unlike placement there is noLink Positionfallback, because a region label cannot manufacture a component identity.pagerank_screaming_frog()does not enable the detector on its own. Passcontainer_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.Placement and recurrence are two detectors feeding one graded axis, not two independent axes. A nav link is boilerplate by construction, so the two factors are not multiplied — that would discount the same link twice for the same fact. The strongest applicable discount wins: chrome
0.1, repetitive in-content0.5, unique in-content1. The transition audit still records both factor sets separately inconfig$placementandconfig$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 inattr(result, "transition_audit")$config$weight_col.Two new presets complete the set:
"reversed"and"content".pr_preset()now registersraw·declared·reversed·content."reversed"is the feeder view (reverse = TRUE); it is a no-op rather than an error undertopic_feeder_pagerank(), which reverses the graph itself."content"carries the placement recipec(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 supplyplacement_col;pagerank_screaming_frog()supplies it from the bundle.New
vignette("presets")documenting every preset’s full expansion, the precedence rule, and how provenance is recorded in the transition audit.Asking for
placement_weightsoraccepted_placementswithoutplacement_colnow names the preset responsible when a preset is what set them, instead of reporting an argument the caller never typed.Placement-aware scoring is now crawler-neutral.
pagerank()gainsplacement_col,accepted_placements, andplacement_weights: pointplacement_colat 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 toNULL, so nothing changes for callers that do not use them, and because they arepagerank()formals they compose withpresetand are inherited by every wrapper that forwards....A Screaming Frog bundle’s
placementis now derived from the DOM path rather than fromLink Position, via a new exportedsf_region_from_path().Link Positionloses the enclosing region whenever a<nav>is nested inside one: on a site whose footer is marked up asfooter > nav > a, Screaming Frog reports every footer link asNavigationand emits noFooterbucket at all, sofooteris 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 emitFooter, so the vocabulary silently varied with the site’s markup. The region is the outermost layout container on the path (header,footer,aside, elsecontent), andnavapplies only to a<nav>that is not inside one of those;Link Positionremains the fallback for rows with no path, counted in the link diagnostics asplacement_from_position_rows. Expect footer and header navigation to reclassify out ofnav— this changes which edgesaccepted_placementsselects, though not the usual recipe, which weights all three alike.The placement vocabulary term
"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 inaccepted_placements/placement_weights.pagerank_screaming_frog()keeps its signature but is now a thin adapter:accepted_placementsandplacement_weightsare forwarded topagerank(), which owns the filtering and weighting. Two consequences for the"screaming_frog_import"attribute:scoring$scored_edge_rowsis renamedscoring$edge_rows_to_pagerank(it counts rows after the wrapper-owned link origin filter, since placement filtering has moved down a layer), andscoring$effective_weight_colis dropped — the transition audit already carries it, alongside a newconfig$placemententry recording the placement column, filter, weights, and the number of rows the filter dropped.New
presetargument onpagerank()and a new exportedpr_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=nofollowignored, 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 howpagerank()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.presetaccepts a preset name, apr_preset()result, or any hand-rolled named list ofpagerank()arguments; bundles are plain named lists, so they are inspectable and spliceable viado.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...topagerank()(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_auditattached to apagerank()result now records which preset produced it, inconfig$preset: the preset name for a registered preset (passed by name or as apr_preset()result),"custom"for a hand-rolled bundle, andNULLwhen 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 aPreset: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()becomesf_contract(),sf_read_input(),sf_parse_follow(),sf_rel_nofollow(),sf_normalize_position(), andsf_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 toolkitwith 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 toresolve_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 theresolve_*family, which pairs an edge-list form with a URL-vector form:resolve_redirects()/resolve_redirect_urls()alongside the existingresolve_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 anedge_list_dfare 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(), andga4_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".
pagerankr 0.1.0
Released 2026-07-11.
Documented that
canonical_profile()deliberately leavesrurl’s component-dropping knobs unpinned (query_handling,port_handling, and theurl_standardselector added inrurl2.2.0) – they have no effect on the scheme+host+path node key at their defaults. Added a behavioral guard intest-canonicalization.Rasserting a canonical key drops the port, query, and fragment, so a futurerurldefault flip on an unpinned knob is caught here rather than silently changing node identity. No node keys change; verified againstrurl2.2.0.canonical_profile()now pinspath_normalization = "dot_segments"andpath_encoding = "decode"(previously"none"/"keep").rurl2.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 therurlupgrade. Thesemanticsibling pins the identical profile (changed together).clean_url_columns()now preserves tokens thatrurlcannot parse as a URL (e.g. a dotless bare label such as"A") as their raw value instead of turning them into NA. Newerrurl(>= 2.1.0) normalizes such dotless tokens to NA; combined with therurlfloor bump in the follow-up, this had silently collapsed non-URL node identities to NA — they were then dropped byget_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 readsrurl’s newdomain_asciicolumn (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.deandxn--mnchen-3ya.destill fold to one key under everyhost_encoding.pagerank_screaming_frog()gainsapply_canonicalsandapply_redirectstoggles (bothTRUEby default, preserving current behavior). Setting either toFALSEskips folding the bundle’s canonical / redirect signals into [pagerank()] (passescanonicals_df/redirects_dfasNULL), 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 rawcanonicals_df/redirects_dfpagerank arguments.screaming_frog_bundle()now reports an off-domain canonical count (counts$canonicals_off_domain, surfaced insummary()/print()), reusing the existing absent-target classification, and the wrapper exposes it on thescreaming_frog_importaudit 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 awarning()naming the merged URL(s) and records them in thefoldsection of thetransition_auditobject under a newcollisionsfield (a data frame oftarget/n_independent_refs/source, orNULLwhen 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 anindexability_dfis supplied.pagerank()now warns when akeep_domains/exclude_domains/keep_hosts/exclude_hostsvalue 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 thepagerank()andfilter_links_by_domain()docs; to scope the crawled input, filter withfilter_links_by_domain()before callingpagerank().out_of_scope_foldgains 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 newleakedterm in thetransition_auditmassaccounting, which now decomposes asreported + sink + leaked + hidden = total(= 1);leakedis0for"relabel"/"keep"runs so their totals are unchanged. Thefoldaudit section reportspolicy == "leak".pagerank()gains anout_of_scope_foldargument ("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 newfoldsection of thetransition_auditobject.Code-quality pass:
anyNA()replacesany(is.na()),!all(x)replacesany(!x), nestedifelse()replaced with vectorized assignment,expect_gt()/expect_lt()/expect_length()/expect_null()adopted where applicable, and redundantc()wrappers around single-string aliases removed.pagerank()andcompute_pagerank()gain convergence controls and reporting. The newalgoargument selects theigraph::page_rank()back-end ("prpack", the fast exact default, or"arpack", the iterative solver), and the friendlyeps/niteraliases re-introduce the L1 tolerance and maximum iteration count that modernigraphdropped, mapping onto the ARPACKoptions$tol/options$maxiter; supplying either transparently switches to ARPACK. Every non-empty result now carries a"convergence"attribute (apagerank_convergenceobject) reporting the solver, iteration count (when the solver exposes it), and a solver-independent post-hoc L1 residual||Gx - x||_1of the returned vector — a genuine quality check comparable across both back-ends. Docs cover the damping/iteration-count rule of thumblog10(eps) / log10(damping).New
topic_feeder_pagerank()answers the inverse oftopic_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 runspagerank()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-pagerankrows withprior_weight == 0(cluster pages carry teleport mass directly). Pure orchestration over the existing TIPR personalization path on the reversed graph — no new solver. Newtopic_feeder_pagerankvignette walks through the AI-Agent-cluster use case and contrasts it with the forward authority view and with HITS hubs.New
salsa()andcompute_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 aspagerank(), 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 (hubisNAfor pure sinks,authorityisNAfor pure sources). v1 is unweighted; a weighted extension is deferred. Documented as a site-graph adaptation of the original focused-subgraph algorithm.New
trustrank()and the sharedseed_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 aprior_dffrom a seed set (character vector or weighteddata.frame; equal weights reproduce the original uniform seed distribution) and is orientation-agnostic — the same builder feedstopic_feeder_pagerank()on the reversed graph.trustrank()is the one-call wrapper that builds the seed prior and runspagerank()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). Newtrustrankvignette walks through a worked example.New
topic_sensitive_pagerank()computes per-topic PageRank by running the existingpagerank()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 weighteddata.frame. Returns one score column per topic plus a weight-normalizedblendedcolumn, with the per-topictransition_auditobjects attached. Pure orchestration over the TIPR personalization path — no new solver, and topic membership is supplied by the caller, not inferred.New
smooth_transitions()shrinks sparse empirical page-transition shares (e.g. fromga4_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 weightlambda_i = n_i / (n_i + k)that increases with the source page’s sample size, withmin_supportfallback to the prior, optional weighted priors, and anorigindiagnostic (both/empirical_only/structural_only). Time decay and device/template/channel segmentation are handled upstream by shaping the count input.New
hits()andcompute_hits()add Kleinberg’s HITS hub and authority scores, computed withigraph::hits_scores()over the same cleaned, redirect/canonical-folded, domain-filtered, deduplicated link graph aspagerank(), so hub, authority, and PageRank share node identities. Docs cover the matrix formulation (authority = dominant eigenvector ofA^T A, hub = dominant eigenvector ofA A^T) and the whole-graph caveat: unlike Kleinberg’s query-focused base set, these are site-wide structural centralities.pagerank()now has an explicitduplicate_edge_policyfor repeatedfrom -> torows 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 withaggregate_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.Recorded representative Screaming Frog crawl acceptance results and added package-level operational documentation for required exports, default graph policy, optional origin/placement policies, and contract pinning.
New
pagerank_screaming_frog()scores ascreaming_frog_bundle()through the existingpagerank()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.New
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.New canonical and composed URL-resolution helpers:
resolve_canonicals(),resolve_canonical_urls(), andresolve_folded_urls()expose the existing fold-map engine for rel=canonical and redirect+canonical URL folding without duplicating resolver logic.Documented the indexed-corpus assumption used by
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.New
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.New
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 atransition_auditprovenance object to its result asattr(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 aprintmethod.The
transition_auditobject’smassfield now decomposes the page-mass deficit precisely intoreported(visible page mass),sink(evaporated nofollow-sink mass),hidden(robots-blocked mass), andtotal(= 1 by construction) — replacing undifferentiated “leakage” language with precise evaporated/hidden accounting.New
aggregate_edges(): loss-aware post-fold edge aggregation with explicit per-column semantics (sum counts, boolean conflict policyany/all/majority/error,preserve_colslist-columns for placement features).New
transform_edge_weights(): per-source grouped weight transforms reusingtransform_weights()methods, emitting a per-sourcetransition_probabilitythat sums to 1 within eachbygroup.New
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 theprior_df/align_prior_to_vertices()machinery; documented as a proxy, distinct from the backlink-authority prior.Initial CI and lint baseline.