Repository navigation
Releases: PavloSEO/spiderhead
Release list
SpiderHead 3.1.0
SpiderHead 3.1.0 — first tagged release after the rename from SEOHEAD Tools.
Changes since 3.0.0
Entries for merged, unreleased work live one file per change in changelog.d/, not here:
python scripts/build_changelog.py folds them in between the markers below at release
time, so two branches never edit this file and can never conflict over it (#638).
-
Keep fingerprinting a page whose site sends a valueless
Set-Cookieheader (#651). A header
with noname=valuepair at all --Set-Cookie: Secure; HttpOnly, whichemall.bysends
beside two well-formed cookies -- becomesCookie(name='Secure', value=None)in the jar, and
httpx.Cookies.__getitem__raisesKeyErrorfor it.dict(resp.cookies)therefore failed on
a name the mapping's own iterator had produced:tech-detectexited 1 with a bare
error: 'Secure'and no JSON on stdout for a page that had been fetched successfully, MCP
seo_tech_detectraised theKeyErrorto its caller, andsite-auditquietly came back with
its entire technology section missing.detect_technow walks the cookie jar instead. A
valueless name is kept with an empty value rather than dropped, because several fingerprints
(BITRIX_SM_GUEST_ID,craft_session,wfvt_) match on cookie name alone and dropping the
entry would lose that signal -- but it is never silently normalized into an ordinary cookie:
the names are reported inmalformed_cookiesand named in a finding, so a header the site
sent broken stays a fact about the site. A response with only well-formed cookies is
unaffected and reports an empty list. -
render-checkmeasures compressed sites again instead of calling them broken (#650). The
pinned render route read the origin with httpx's undecoded stream and handed those bytes to
Playwright together with the origin'scontent-encoding.route.fulfillnever applies a
content coding to the body it is given, so Chromium parsed gzip astext/htmland built a DOM
out of the compressed stream: no title, no h1, no canonical, no links, and a rendered size
equal to the transfer. Every site that compresses its HTML -- nearly all of them -- came back
ok: false, reason: incomplete_render, and the message blamed the render, suggesting
--wait domcontentloadedor a longer--timeoutwhen the render had finished perfectly.
seohead.techreported 22 435 rendered bytes against 121 793 raw; it now reports 123 048 with
the real title and 31 links. The route now reads the decoded entity, dropscontent-encoding
from what it forwards (content-lengthwas already dropped as hop-by-hop, so Playwright states
the length of the body it actually received), and asks the origin only for the codings this
client can decode rather than forwarding Chromium's own invitation tobrandzstd. A coding
that arrives undecoded anyway aborts the request and names itself, because passing it through
would reproduce the bug in silence. The byte cap now counts decoded bytes, which is the body
the renderer sees -- a compression bomb previously passed the cap compressed. -
Read Open Graph off a native crawl by the key the parser actually writes (#646).
parse_htmlstores each tag under its full property name --{"og:title": ...}, as its own
docstring says -- andcrawl/collect.pyasked the same dict for"title","description"and
"image".dict.getansweredNoneevery time, soog_title,og_descriptionandog_image
were empty on every page of every native crawl ever run: 0 of 40 920 pages carried one on the
run that surfaced this, on a site whose pages do publish Open Graph. That is the repository's
central rule inverted -- an unmeasured field read as a measured one -- and it travelled, because
crawl/evidence.pyprojects those columns intoOG:Title,OG:DescriptionandOG:Imageof
the Internal:All frame, so a check saw blank on a native crawl and the real value on a Screaming
Frog export of the same site.check_og's honesty contract hid the damage rather than showing
it: with no OG column populated anywhere it skipped, so a site with Open Graph audited
identically to a site without it. The parser is unchanged; only the read side moved. A new
tests/test_open_graph_seam.pycrawls two loopback fixture sites and follows the value from
parser topages.jsonlto thepagestable of the scan artifact to theOG:*frame columns,
crawls a second site with no Open Graph to hold "empty because absent" apart from "empty because
unread", and pins the parser's key shape in the same file -- the failure was two correct modules
disagreeing about a key name with no test spanning both. -
Answer "is this site linked well, and where is it linked badly", taking the registry from 157
to 161 checks and adding aninternal-linkingskill (#634). The pieces existed -- every edge
with its position, anchor and nofollow flag in thelinkstable,ORPHAN_PAGE,
INLINK_BOILERPLATE_ONLY,LOW_LINK_SCORE-- and nothing turned them into an answer about the
graph. A 40 920-page audit carried the wrong headline for hours because of it: 66% of the
sitemap was reported unreachable by internal links, a figure produced by subtracting a crawl's
URL count from the sitemap's, which measures "the crawler did not get there". Walking the
artifact's own 5 252 235 edges from the home page put reachability at 91.9%. The real defect
was depth: 23 742 pages -- 58% of the site -- were more than ten clicks from the home page, and
the deepest was 3 005, because the archive is a linked list of "next post" links rather than a
tree. Click depth is now the headline the audit prints.summary.internal_linkingcarries the
histogram, the maximum, the reachable/unreachable split, internal edges by page position, and
how many edges repeat another edge, andaudit.mdrenders it beside the health summary. The
walk starts at the URL the crawl actually began from, never atpages.crawl_depth: that column
records where the crawler happened to reach a URL, and a sitemap-seeded crawl records 0 for
most of a site, so a walk seeded from it describes somewhere else. When no start URL was
recorded and more than one page claims depth 0, the measurement refuses to run and says which. -
Four checks read those numbers.
DEEP_CLICK_DEPTHfires on an indexable page whose shortest
route from the start URL exceedsthresholds.click_depth_max(default 10, and every finding
names the number it used).DUPLICATE_INTERNAL_LINKfires on a page that writes the same
destination and anchor more than once: on the site above, 509 744 of 1 134 307 edges -- 44.9%
-- were repeats, and they were one masthead emitted twice with the second copy removed by
JavaScript, a template defect that had taken a hand-written script to find.LINK_INSIDE_HEADING
andIMAGE_LINK_WITHOUT_TEXTneed what a link's page region cannot carry -- the anchor's own
ancestor chain and contents -- so a native crawl now records a small, capped per-page
link_placement_jsonbeside the heading outline, and both checks skip by name on a Screaming
Frog export rather than run clean on nothing. -
The honesty rules the rest of the analyzer holds apply throughout. An edge with no recorded
position is counted asunclassifiedand never folded intocontent:link_position.classify
defaults to off, which leaves that on every edge, and a run in that state must read as "nobody
looked" rather than as a site whose links are all body copy. A position absent from the
distribution is reported as "no edges classified here", not as a region the site lacks -- rules
are tried in order and a menu inside<header>matchesnavfirst. A partial crawl withdraws
the click-depth verdict whether or not it fired, because a frontier never fetched may hold the
shorter route, and a check silently finding nothing would read as "every page is within the
floor". -
Stop reporting a requirement-gated check as clean when it was never evaluated (#635).
H2_MISSINGonly fires when a configuration setsrequirements.require_h2, and the default
is false, so on an ordinary run its branch is unreachable. It left no trace of that: coverage
counted it among the silent checks -- the bucket that means "ran over every page and found
nothing" -- and a reader was told the site passed a check nobody ran. On the audit that
surfaced this, 61% of the site's pages had no H2 at all and 5 066 of those were longer than
800 words, so the check would have fired thousands of times. It is now declared skipped by
name, with a reason naming the setting that turned it off, so the answer is "not evaluated,
and here is how to evaluate it" rather than silence.require_canonicalis the same shape
and gets the same treatment; its default is true, so it was not producing a wrong reading
yet, and now cannot start. -
Fix the task backlog heading reading "Audit Tasks — None" for any audit whose run
recorded no project (#640).render_tasks_mdbuilt the heading with
src.get("project", "site"), but"project"is always a present key in the backlog's
sourceblock -- it is set at build time fromrun.get("project"), so a run with no
project stores the valueNonethere rather than leaving the key absent, and
dict.get's default only fires for a missing key, never for one holdingNone. A
native crawl and a reanalysis both leaveprojectunset, so every backlog built from
either got the literal word "None" as its heading. The heading now falls back with
or, which does catch a storedNone, and reaches for the site'sstart_urlor
sourcebefore giving up -- the same orderseohead.reports.facts.crawl_domain
already used to resolve a site's name from itsrunblock, now exported and reused
here instead of reinvented, so a ba...