Versions follow MAJOR.MINOR.PATCH (src/version.py); Apify shows MAJOR.MINOR from .actor/actor.json.
Every run logs its version and records it in the RUN_STATS key-value record.
- A run that returns nothing (every input bad, private, blocked or unreachable, or an empty input) now ends
SUCCEEDED with an empty dataset and the reason as its status message and in the log, instead of FAILED. Only an
unexpected error still fails the run.
RUN_STATS keeps the reason as before.
- No change to output fields or price.
- A run no longer fails when the store it remembers earlier runs in can't be opened (403: the account already has
a store of that name that this actor didn't create). It falls back to
humble-echidna-<name>, and if that is
refused too it runs without memory for that run. Users' existing memory is untouched: the usual name is tried first.
- Waits out longer
Retry-After answers: when a site or API asks it to wait before retrying, it now waits up
to 90 seconds (was 30), once per site, with at most 3 minutes of such long waits per run; a longer wait, or a
site that asks again after it was waited out, is reported with the reason instead of stalling the run.
A link check still has 60 seconds in all, so a site asking for longer than fits is reported as rate-limited at once.
- No change to input, output or price.
startUrls is no longer marked required, so AI agents (Apify's MCP server) see that a dataset alone (datasetId) is enough. Nothing else changes: a run with neither still stops with a clear message.
- 2 ready-to-run examples published on Apify Store and linked from the README.
- Cheaper on paid Apify plans: $9.00 per 1,000 pages on Starter, $8.00 on Scale and $7.00 on Business ($10.00 on the Free plan).
Audit the websites in another actor's results, e.g. Google Maps leads with weak websites.
- New inputs Or: websites from a dataset (
datasetId, a dataset picker with read access) and Field with the
website (datasetUrlField). With a dataset, each item's website becomes a start URL and startUrls is ignored;
with Crawl depth 0 that's one home page per business, and at higher depths every page found through a website
carries its source fields. The field is found automatically when left empty: the first of website, url,
domain, site, homepage with a website in the first 100 items, then the same names one level down
(contact.website); a dotted path names a nested one.
- The same website twice (by its canonical address, tracking parameters aside) is audited and charged once, with the
first item's source fields.
- Items without a website, values that aren't a public web address (a phone number,
N/A, an email) and Google
Maps links (the place's own url in a Maps scraper's output) are skipped, never fetched or charged, and counted in
the new RUN_STATS.dataset record (itemsRead, websites, withoutWebsite, notAWebsite, googleMapsLinks,
duplicates, the field used and whether it was found automatically). Tracking parameters (utm_*, gclid,
fbclid, ...) are dropped from the address. A dataset with no website at all, or one that can't be read, fails
the run with the reason.
- New nullable fields on every result:
sourceTitle (the item's title, else name), sourcePlaceId (its
placeId) and sourceIndex (its position in the dataset, 0 = the first), to join results back to the leads;
null for URLs typed in. A new "Leads (from a dataset)" dataset view shows them next to the key columns.
- Read with the run's own token (the user's access, read-only), only the fields needed (whole items only
for the first 100 when the field is looked for one level down); at most the first 20,000
items and 10,000 distinct websites per run. Max results / maximum cost per run apply as before. Outside Apify, a
dataset in local storage with that name is read.
- Pricing unchanged: no new events; a website from a dataset costs the same as one typed in.
- Internal: the reader is shared (
mms_common.dataset_input), as are its test stand-ins (mms_testing).
- README: real screenshots of the input form and the Output table, from our own runs.
- Sitemap mode: a sitemap that also lists another site's URLs is read to the end. It used to stop at that batch
and mark the audit incomplete, which held back removed pages in the comparison.
- A 0-100 score per page and for the site, from a published formula (README: How the score is calculated): 100,
minus 20 per error, 8 per warning and 2 per notice issue code on the page (each code once), 0 for a page that
doesn't load (
http-error, request-failed), never below 0. slow-response isn't counted (response times vary run
to run). New field score; SUMMARY gets siteScore (the average over every page audited, halves rounded up),
scoreBands (90-100, 50-89, 0-49) and lowestScorePages; RUN_STATS gets siteScore; the status message
shows it. The README's issue table has the points for every code, checked by a test.
- Image checks. Read from the page:
imagesEmptyAlt (alt="" present but empty), imagesMissingSize (no width
and height attributes; new notice images-missing-size), imageLinksWithoutText (a link whose only content is
images without alt text; new warning image-links-without-text). With the new Check images input (on by
default), each distinct <img src> gets one HEAD request per run (GET only when HEAD answers an error), wherever
it's hosted, counted against Max link checks per run: brokenImages / brokenImageCount (new error
broken-images) and oversizedImages over 200 KB by Content-Length (new warning oversized-images);
imagesNotChecked counts the rest. SUMMARY lists broken and oversized images once each. RUN_STATS counts
imagesChecked apart from linksChecked. Not measured, and the README says so: files without a Content-Length,
pixel size, format and compression, srcset/<picture>/lazy-loading/CSS images.
- Comparison (1.1's
compareWithPreviousAudit / onlyChangedPages): a page whose score changed has score in
changedFields and previousScore; since the score is computed from the compared issue codes, a score change
always comes with an issue added or fixed, so it counts as a change (and in the only-changed mode the page is
returned). SUMMARY.comparison gets siteScore, previousSiteScore and siteScoreChange. Broken and oversized
images are carried like broken links when an image had no clear answer (not checked, or refused). An audit
remembered by 1.1 isn't flooded on upgrade: the image issues found on a page the first time it's compared again
are recorded, not reported as added (pages the crawl doesn't reach stay marked until it does). The memory record
gains checks, legacy and siteScore; its format is otherwise unchanged, and turning Check images off is a
separate audit (its own baseline), while the default keeps 1.1's comparison.
- No new charged events: image checks and scores are free, like link checks. Image requests share the existing
link-check cap, so a run's worst case is unchanged.
- Changes since the last audit (
compareWithPreviousAudit, off by default): each page gets changeType (new,
changed or unchanged), changedFields (status code, redirect target, title, meta description, H1s, canonical
URL, noindex, broken links), changedIssues (issue codes added and fixed, broken links new on the page, how many
were fixed) and previousStatusCode. The SUMMARY record gets a comparison: the previous audit's date, run and
dataset IDs; pages new, changed, unchanged and removed; issues added and fixed per code; new and fixed broken
links. The first run with it on is the baseline (nothing reported as new).
- Only return changed pages (
onlyChangedPages, off by default; turns the comparison on): new and changed
pages are written and charged as usual ($10.00/1k); unchanged pages are still audited and counted in SUMMARY but
not written, and are charged the new unchanged-page event, $1.00/1k (a custom event, not a dataset row, so
never billed twice; charged in batches of 100; never for a page that refused, failed or answered 5xx again). The
baseline run charges every page as a row. In this mode the maximum cost per run limits the pages read: before a
page is read, the least it can cost is reserved (the unchanged price, or the page price on the baseline); a
changed page is written only if the rest of the page price fits, else it's reported again next run. Nothing is
reserved for unchanged pages while the event isn't priced on Apify.
- The memory is one record per audit (mode, start URLs and settings, hashed) in the user's own
seo-audit-memory
key-value store: per page the status code and short fingerprints of the fields, issue codes and broken links, no
page content (about 200 bytes a page). Only pages the user got (written, or unchanged in the only-changed mode)
update it, so a page cut by the maximum cost per run is reported again next run.
- Guards: pages are reported removed only when the crawl was complete (not cut by max pages or the maximum cost, no
failed start page or sitemap, no page that refused, failed or answered 5xx); otherwise
pagesRemoved is null,
incompleteReasons says why, and those pages stay remembered. A broken link without a clear answer this run (not
checked, refused, rate-limited) isn't reported fixed. slow-response isn't compared (timing noise).
- With both options off, the crawl, the requests, the charges and every existing field are as in 1.0.1; the four new
fields are
null (or empty) and SUMMARY.comparison is null.
RUN_STATS gains compare, onlyChangedPages, baseline, pagesNew, pagesChanged, pagesUnchanged,
pagesRemoved, unchangedNotReturned, crawlComplete and incompleteReasons; pagesAudited now counts
unchanged pages that weren't returned (pushed is still the rows charged); unchangedPagesCharged and
unchangedPagesFree count the rechecked pages charged and not charged.
- The link-check scheduling (hosts take turns, at most 2 checks at a time each; a host that answers 429 is left
unchecked for the rest of the run) moved to the shared
common/mms_linkcheck, so Bulk URL & Broken Link Checker
uses it too. Same results; one difference in timing: a worker no longer waits for a busy host while another
host's link is ready.
First release.
- Audits the on-page SEO of a site the user gives it: pages are found by crawling same-site links from start URLs,
or from the site's sitemaps (robots.txt
Sitemap: lines, else /sitemap.xml; the same discovery as Sitemap URL
Extractor), up to a crawl depth and a max pages per run.
- Per page: HTTP status, redirect chain, response time, content type and size, title and its length, meta
description and its length, H1 count and text, canonical URL, robots meta and X-Robots-Tag, indexable,
<html lang>, hreflang alternates, Open Graph and Twitter tags present, JSON-LD @type values (types only),
image count and alt coverage, internal/external link counts, word count, broken links, and a list of issues with a
severity (error, warning, notice).
- Broken links: every distinct link on the audited pages is checked once per run with the same checker as Bulk URL &
Broken Link Checker (HEAD first, GET when HEAD answers an error); links to pages the crawl reads reuse that
answer. Optional, with a per-run cap and an on/off switch for other sites' links. Hosts take turns (at most 2
checks at a time each), and a host that answers 429 is left unchecked for the rest of the run, so one
rate-limiting site can't stall the run.
- A site summary in the
SUMMARY key-value record (not charged): status codes, issue totals per severity and per
issue, worst pages, duplicate titles and meta descriptions, every broken link with the pages it's on, slowest
pages, averages, structured data types, and the pages that were never requested.
- robots.txt respected on every request and redirect hop (a disallowed page is never requested and not charged);
Crawl-delay honoured; at most 2 requests at a time per site; public addresses on ports 80 and 443 only.
- No third-party SEO data (no rankings, backlinks or traffic estimates): only what the site itself serves.
- Charged per page audited, through Apify's standard
apify-default-dataset-item event. Max pages per run and the
maximum cost per run are honoured before a page is requested.
- Failure isolation: a page that can't be read, or a start URL that fails, only affects itself.