# Website Audit & SEO Checker for Local Business Leads (`maxbuildog/website-audit-seo-checker`) Actor

Bulk website audit for local businesses: HTTPS, mobile, SEO basics, outdated technology, contact paths. Reads Google Maps scraper output and writes a plain-words pitch line for each owner.

- **URL**: https://apify.com/maxbuildog/website-audit-seo-checker.md
- **Developed by:** [LAMOTTE LLC](https://apify.com/maxbuildog) (community)
- **Categories:** Lead generation, SEO tools
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.80 / 1,000 website auditeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Audit & SEO Checker for Local Business Leads

**Bulk website audit and SEO audit for local business websites, built for lead generation.** This website checker reads a list of sites, or the output of a Google Maps scraper, and returns one row per entry: name, city and trade, the problems an owner understands (no HTTPS, not mobile friendly, an old year in the footer, a phone number that cannot be tapped, invisible on Google), an outdated technology score, a health score, a technical summary and a plain-words pitch line. It is made for web agencies, SEO freelancers and lead generation teams who prospect local business websites. No browser, so it is cheap: under 1 second of run time per site on a 60-site run on the Apify platform (0.9 s measured, see Pricing).

### What does this Actor do?

For every website it reads the home page and the contact page (up to five pages if you ask), then checks:

- **Security**: HTTPS, certificate validity and days left, http to https redirect, mixed content.
- **Mobile**: viewport tag (also when a script of the site or of its platform adds it; before calling it missing, the home page is read as a phone receives it), and tap-to-call links as a phone receives the page.
- **SEO basics**: title, meta description, H1, canonical, noindex, robots.txt, sitemap (absent, or declared but broken), structured data (LocalBusiness, Organization), images without alt text.
- **Outdated technology**: copyright year as the visitor sees it, CMS and version (WordPress, Joomla, Drupal, PrestaShop and hosted builders: Wix, Squarespace, Duda, GoDaddy, Webflow, Webador, Shopify...), jQuery and Bootstrap versions, PHP version, table layouts, Flash and frames, template builders of the 2000s, free sub-domains.
- **Conversion**: contact form, phone shown, tap-to-call link, email shown, booking widget, map, company social pages.
- **Tracking**: Google Analytics 4, old Universal Analytics, Tag Manager, Meta Pixel, about 30 other tools, built-in statistics of hosted builders, cookie consent banner.
- **Links**: a sample of internal links checked for 404 and server errors (links hidden from visitors are skipped).

It also finds **who the business is**: `businessName`, `city`, `postalCode` and `category`, from schema.org markup, Open Graph tags, the domain written as words on the page, the page title, the copyright holder in the footer, the logo, a heading with a legal form and the H1. No email address and no phone number is ever written to a row, in these fields or in any other: a final check removes any that a field still holds.

Pages that are not the business site, and sites that cannot be audited honestly, get their own status and are **not charged** (`billed` is `false` on their row): unreachable sites (after a second try, error pages answered with a 200 included), sites that block bots, answer with an anti-bot challenge or a sign-in page, or refuse the country of the request (`blocked`), pages built only by JavaScript (`js_rendered_shell`), empty or oversized pages, parked, expired or for-sale domains, and expired domains taken over by spam (`parked`), "site disabled" or "account suspended" pages of a web host (`suspended`), **pages that only say "under construction", "coming soon" or "maintenance", and the default page of a web server (`placeholder`)**, and businesses listed without a website, or whose address only redirects to a social network page (`no_website`). A `placeholder` row is the same lead as a `no_website` row (the business has an address but no site yet) and is free like it, even when the waiting page also shows the contact details of the business: the pitch line then says so.

**Release 0.8** is a security release after an offensive audit of the code (no change to the checks, the scores or the output columns): the reading of a page is linear in its size and runs in a worker thread with limits of time and memory, so that a hostile page cannot hold the run; the spending limit is never exceeded; a restarted run does not audit or charge a site twice; contact details written with other digits, invisible characters or spelled-out addresses are removed too; text that a spreadsheet would run as a formula is neutralised; a trailing dot, an unusual port, an oversized field or a robots.txt that forbids the sitemap are handled as described below.

These pages are recognised by their patterns, and new kinds keep appearing. In the last independent test (release 0.6, fresh pages from 74 towns never used before), 281 of 304 pages that are not the business site were recognised and not charged (92 %; 84 % of those served with a 200 status, the ones that must be read to be told apart). No real site was put in `parked` or `suspended`; one "coming soon" page that also showed the company's address was marked `placeholder`. On a random draw of 360 directory listings, 4 of 242 charged rows (1.7 %) were not a business site. Release 0.7 adds the families that this test found still charged (a JavaScript browser check served with a 200, an address that redirects to a social network, an expired domain taken over by an IPTV or spyware shop, a registrar page, more suspension, default, error and waiting pages, the password page of a Shopify store that is not open yet); the figures above are those of release 0.6 and do not include these additions. A page of this kind that is not recognised is audited, and charged, like a site.

### Who is it for?

- **Web design and SEO agencies and freelancers** who want a list of local businesses whose website is insecure, not made for phones or showing its age in its code, with one sentence to open the conversation.
- **Lead generation teams and prospecting tools** that chain a Google Maps scraper and this Actor, and join the results back on the place ID.
- **Consultants** who need the same technical check across hundreds of sites.

### How to use it with a Google Maps scraper

1. Run a Google Maps scraper from the Apify Store for a trade and a city (for example "plumber, Lyon").
2. Pick its dataset in **Dataset of a Google Maps scraper** (`datasetId`). The Actor audits each `website` and copies `title`, `categoryName`, `city`, `placeId` and the Google Maps URL to `businessName`, `category`, `city`, `placeId` and `googleMapsUrl`. Phone numbers, emails, ratings and reviews of the source are ignored and never copied.
3. Items without a website come back as `no_website`, with their `placeId` and a pitch line for a first website. They are free (`billed` is `false`).
4. Two listings that share one website (a plumber and its heating branch, for example) give two rows: the second gets the status `duplicate`, a copy of the audit with its own name, `placeId` and `externalId`, and is not charged again (`duplicateOf` says which row it copies).
5. Open the **Overview** view or export it as CSV. Sort by `healthScore` (lowest first) or filter `clearlyOutdated = true`.

**Integration (no code)**: in the Google Maps scraper, add an integration "Run Actor" that starts this Actor when the scraper run succeeds, with the input:

```json
{ "datasetId": "{{resource.defaultDatasetId}}", "startUrls": [] }
```

`{{resource.defaultDatasetId}}` is replaced by the dataset of the run that just finished. To get your own identifier back, put it in `externalId` on each item of `businesses`, or in `userData.externalId` of a start URL.

### Input

| Field | What it does |
|---|---|
| `startUrls` | Websites with a scheme (`https://example.com`). Clear the example list if you use another field. |
| `domains` | Bare domains, one per line (`example.com`). |
| `datasetId` | Dataset of a Google Maps scraper (items with a `website` field), chosen from your datasets. |
| `businesses` | Advanced: a JSON list of objects with `website`, `title`, `categoryName`, `city`, `placeId`, `externalId`. |
| `maxSites` | Stop after this many distinct sites. |
| `maxPagesPerSite` | 1 to 5, default 2 (home page plus the contact page). |
| `checkBrokenLinks` | Sample 10 internal links, default on. |
| `language` | `auto` (language of each site, French or English), `en` or `fr`. |
| `pageSpeedApiKey` | Optional: your own Google PageSpeed key, to measure mobile performance instead of estimating it. |
| `proxyConfiguration` | Optional, only for sites that block datacenter IPs or refuse the country of the request. |
| `maxConcurrency` | 1 to 50, default 10. |

A website that appears twice in the input is audited once; the second entry gets a non-charged copy (status `duplicate`). A domain written with a final dot (`example.fr.`) is the same site as `example.fr`. Only the web ports 80, 443, 8080 and 8443 are audited (a start URL on another port is `unreachable` with `unsupported_port`, and so are the links to other ports). The text fields copied from an entry (`title`, `categoryName`, `city`) are cut at 200 characters. The end of an input longer than ten times `maxSites` (at least 1,000 entries, at most 100,000) is ignored and counted in `ignoredEntries` of `RUN_SUMMARY`: entries without a website cost nothing to read but are written as free rows.

### Output

One row per input entry (entries beyond `maxSites`, or not reached when your maximum charge per run stops the run, have no row), with flat columns for spreadsheets (the **Overview** view has 35 scalar columns, no nested JSON, among them `billed` and `outdatedSignals`, what makes the outdated technology score, in one cell) and detailed objects for developers. A real row from our tests (shortened; the business is a French electrician, the language follows the site):

```json
{
  "inputUrl": "https://www.electricitesystemes.com/",
  "status": "ok",
  "billed": true,
  "domain": "electricitesystemes.com",
  "businessName": "Dumont Electricité Système",
  "businessNameSource": "og_site_name",
  "city": "CLERMONT-FERRAND",
  "postalCode": "63100",
  "category": "Électricien",
  "placeId": null,
  "platform": "WordPress",
  "platformVersion": "5.9.2",
  "copyrightYear": 2019,
  "jqueryVersion": "3.6.0",
  "phpVersion": null,
  "healthScore": 78,
  "outdatedTechScore": 28,
  "clearlyOutdated": true,
  "outdatedSignals": "COPYRIGHT_6Y_PLUS; JQUERY_3_5_OR_3_6; OUTDATED_CMS",
  "warningIssueCount": 6,
  "topIssues": "OLD_COPYRIGHT; OUTDATED_CMS; LEGACY_UNIVERSAL_ANALYTICS",
  "summary": "Dumont Electricité Système (CLERMONT-FERRAND) paraît daté : copyright 2019 (7 ans), WordPress 5.9.2, charge des traceurs avant l'accord des visiteurs et utilise encore Universal Analytics (arrêté en 2023).",
  "pitchLine": "Votre site affiche encore 2019 en bas de page, repose sur des logiciels qui ne sont plus mis à jour et ne compte plus vos visites depuis juillet 2023.",
  "isMobileFriendly": true,
  "hasContactForm": true,
  "hasPhoneVisible": true,
  "outdatedTechSignals": ["COPYRIGHT_6Y_PLUS", "JQUERY_3_5_OR_3_6", "OUTDATED_CMS"],
  "issues": [
    { "code": "OLD_COPYRIGHT", "category": "freshness", "severity": "warning", "message": "Le copyright du pied de page indique 2019 (il y a 7 ans).", "fix": "Mettre à jour l'année du pied de page (ou la rendre automatique) et relire le contenu du site." }
  ]
}
```

`status` is `ok` (audited and charged) or one of `unreachable`, `blocked`, `not_html`, `js_rendered_shell`, `empty_page`, `truncated`, `parked`, `suspended`, `placeholder`, `no_website`, `duplicate` (a row with one of these statuses is not charged). `statusReason` says why (`timeout`, `dns_not_found`, `http_403`, `host_error_page`, `challenge_page`, `login_page`, `endless_response`, `geo_blocked`, `parked_domain_lander`, `redirect_to_parking`, `domain_taken_over`, `hosting_disabled`, `under_construction`, `maintenance_page`, `default_server_page`, `redirects_to_social_page`, `too_many_elements`, `too_deep`, `analysis_timeout`, `analysis_failed`, `unsupported_port`, `same_website:ok`...).

`billed` is on every row: `true` if and only if the row was charged (status `ok`, one `website-audited` event), `false` for every other row. Filter on it to count what a run cost; the number of rows with `billed: true` is the number of charged events.

#### Safe to open in a spreadsheet

The text fields come from the sites and from your listings, and a spreadsheet runs a cell that starts with `=`, `+`, `-` or `@` as a formula. Since release 0.8 every text that starts with one of those four characters (after any space, tab or carriage return) gets an apostrophe in front (`'=1+1`), and control characters are replaced by a space. Web addresses (`http://` and `https://`) and plain numbers (`-5`, `+7.5`) are left as they are. A name that really starts with a dash, such as `-Dental`, comes back as `'-Dental`.

#### Pitch line and summary

- `pitchLine` is one sentence for the owner, in plain words, with no version numbers and no jargon: "Your website is not secure (browsers show "Not secure"), does not adapt to phones and still shows 2014 at the bottom of the page." It only uses problems raised as warning or critical, it never names the business (the name is a guess, the facts are not) and it is `null` when nothing is worth pitching. Rows `no_website`, `parked`, `suspended` and `placeholder` get a line too ("Your web address only shows a maintenance page, so customers who look you up cannot see your website.").
- `summary` is the technical note for you: business name, then the two or three problems that matter most, with versions and dates. "Looks dated" is only written when there is a sign of real age: a footer year of 6 years or more, abandoned technology, a table layout, or a version from before 2013. A jQuery 1.11 on its own stays a fact of the row, not a judgement.

#### The two scores

- `healthScore` (0 to 100, higher is healthier): eight category scores mixed, with a penalty for the worst one. Sort it from lowest to highest to put the sites with the most technical problems first.
- `outdatedTechScore` (0 to 100, "Outdated technology"): the sum of signs of old technology in the HTML (old copyright, no mobile viewport, old jQuery, WordPress or PHP, table layout, Flash, template builders of the 2000s, free sub-domain), listed in `outdatedTechSignals` (and in the `outdatedSignals` column of the Overview). `clearlyOutdated` is `true` from 25 points. **It spots old technology, not an old look.** Most sites that look dated but run on current technology score 0 or close to it, and the markup of current builders (Wix, Duda, GoDaddy, Squarespace) is not counted as age. Use it to find sites with old code, not to rank designs (see Accuracy for what it misses).

#### Issue severities

`critical` (no HTTPS, expired or invalid certificate, noindex, robots.txt blocking everything, no title), `warning` (a problem worth raising with the owner) and `info` (small points, and checks that could not be confirmed without a browser, for example "no contact method", or a number that cannot be tapped when the phone version of the page could not be read). Every issue comes with a plain-language `message` and a `fix`.

### Pricing

Pay per event, nothing else: **$4 per 1,000 audited sites, nothing charged for unreachable, blocked, parked, suspended, placeholder (a page that only says "under construction", "coming soon" or "maintenance", or the default page of a server), no-website or duplicate rows** (event `website-audited`, one per row with status `ok`, that is `billed: true`; JavaScript-only, empty and oversized pages are not charged either). The run stops cleanly when your maximum charge per run is reached: no row is charged beyond it (an audit that ends after the limit was reached is dropped, neither written nor charged), and `ok` in `RUN_SUMMARY` is the number of `ok` rows written. Before release 0.6, an "under construction" page was charged as a site; it is free now, like a listing without a website.

**Speed**: 0.9 s of run time per site on a 60-site run on the Apify platform at the default 1,024 MB and concurrency 10 (release 0.6, measured by an independent reviewer: 55 s for 60 sites, against 119 s for release 0.5), about $0.07 of platform cost per 1,000 sites (1.6 % of the price), and 238 MB of memory at most. At the same rate 1,000 sites take about 15 minutes. At 512 MB the run is limited by the processor share (3.3 s per site with release 0.3) for almost the same platform cost.

### Accuracy

Every release is measured on real small-business sites from France, the US, the UK, Canada and Australia by an independent reviewer who did not write the code: each problem raised as warning or critical is re-checked with a real browser (rendered page on desktop and on a phone, network requests) or with fresh requests. A figure is only given when it comes from sites that were never used to tune the rule it measures. The figures below come from the last independent test (evaluation 5, release 0.5: 591 audited rows of fresh small-business sites, 254 of them drawn at random from directory listings, never used to tune a rule). Figures of release 0.5; release 0.6 raised the same problems and the same pitch lines on 435 fresh rows audited by both releases. Release 0.7 changes none of the checks of this table, except that a link written as a bare email address or phone number is no longer tested as a page.

| Problem (warning level) | Re-checked | Confirmed |
|---|---|---|
| No H1 heading | 194 | 97 % |
| Phone number not tap-to-call | 188 | 96 % (99 % without 7 debatable cases) |
| No analytics (builder statistics count as analytics) | 154 | 99 % |
| No meta description | 152 | 100 % |
| No sitemap | 143 | 99 % |
| No mobile viewport | 103 | 96 % |
| Old copyright year | 98 | 98 % (95 % when the year shown must match the page exactly) |
| Outdated jQuery | 82 | 99 % |
| http:// does not redirect to https:// | 75 | 100 % |
| Images without alt text | 66 | 100 % |
| Trackers before consent (EU sites) | 58 | 97 % |
| Old PHP version announced by the server | 55 | 100 % |
| Universal Analytics still in place | 48 | 100 % |
| Fixed-width viewport | 34 | 97 % |
| Broken internal links (404 or 5xx) | 32 | 97 % |

The six checks that release 0.5 changed (tap-to-call, viewport, fixed-width viewport, analytics, trackers before consent, copyright year) are in the table above and all reach 95 % or more on at least 30 sites. What changed in release 0.5, for reference:

- **Phone number not tap-to-call**: Wix does not always serve the same phone page, so the phone version of the page is read before saying the number cannot be tapped. On 85 Wix sites, 49 were cleared that way.
- **No mobile viewport** and **fixed-width viewport**: some sites give the tag to phones only, and one builder rewrites it with its scripts, so the home page is read as a phone receives it before raising either: 35 sites were cleared, all of them rightly. The 4 wrong cases were a tag added by a script, and a tag after the first 200,000 characters (release 0.6 reads the whole head).
- **No analytics**: also finds the statistics that a consent manager loads after the visitor agrees.
- **Old copyright year**: ignores the year of a photo credit in a picture caption. The debatable cases are years next to the year of the page (2023 against 2022).
- **Trackers before consent** (EU sites): the problem is worded as "loads trackers before visitors agree". On 54 confirmed sites relooked at the next day, 47 set a tracking cookie before any click; on the 7 others the tracker no longer ran, or ran without a cookie.

Checks seen on fewer than 30 sites (no HTTPS 21, certificate problems 26, outdated CMS 16, mixed content 13, noindex 13, ...) were all confirmed, on samples too small to prove a rate. "No contact method" is informative only: contact details added by the scripts of site builders cannot be seen without a browser.

**Business name, city, category: check them before you use them.** Business name right on 48 of 66 fresh sites checked by two independent reviewers (73 %; 85 % counting a legal form, a trade word of the sign or a short form as right), city right on 34 of the 39 rows that had one (87 %; 41 % of rows have no city), category filled on one row in three and right on 18 of 22 (release 0.6). The usual faults: the domain written in one word in place of the name, the name of another organisation (a web agency, a parent company), a trade or a town stuck to the name, a word of the page or a unit number stuck to the town, a landmark in place of the town, a region or a country in place of the town. Release 0.7 corrects these families; its figures are those of release 0.6 until an independent test measures it. When you pass the Google Maps `title`, `city` and `categoryName`, they are copied as they are and win over the page. In the last test, one email address reached the output: a link written without `mailto:` was stored as a broken link. Release 0.7 does not test such a link and removes every email address and phone number from every field of a row before it is written.

**Blocked sites**: with the user agent `WebsiteAudit`, 4.2 % of 360 listings drawn at random came back `blocked` in the last test, 0.3 % for this tool only (the others refuse any automated request, a browser without JavaScript included). Timeouts: 1.1 %. A third of the listings of a directory had no readable site (29 % unreachable, mostly dead domains): you pay only for the rest.

**Pitch line**: in the last blind test, the facts of 28 of 30 lines drawn at random were exact and the wording had no jargon in 30 of 30. 17 of 30 could be sent as they are; 11 were exact but thin (one minor point such as a number that cannot be tapped, a footer year or a broken link) and need a second fact; 2 were not usable (a spam site on an expired domain). The `summary` is for you, not for the owner: 3 of 30 carried a stray word stuck to the town or the name.

**Scores** (not measured again in the last test: figures of release 0.4; sites rated 1 to 5 from screenshots by the independent reviewer before seeing any output): the rank correlation of `outdatedTechScore` with "this business needs a new site" is 0.40 on 194 small businesses, and only 0.17 on the 111 of them that were drawn without looking at their code (the others were picked partly for signs of old code, which the score reads). `clearlyOutdated` flagged 20 of the 194: 12 were rated as real redesign prospects and 1 was rated modern; it found 9 of the 13 sites rated 4 or 5. On sites drawn at random, about 6 % are flagged. **Most visually dated sites are missed**, because their age is in the look, not in the code. In release 0.5 a missing viewport no longer counts when the phone version of the page has one.

### Limits

- **No JavaScript is executed.** A site that builds its page in the browser is reported as `js_rendered_shell` and not audited. A viewport tag, phone link or analytics tag added by the site's own scripts is looked for in those scripts; one added by a third-party script or after a click is not seen. This is why "no contact method" stays informative.
- **Home page and contact page only** (up to five pages with `maxPagesPerSite`). A site whose content sits behind an "enter" page, or whose contact details are on a deeper page, can be misjudged.
- **Timeouts.** A site that does not answer within 15 seconds is tried again at the end of the run with 30 seconds; the log shows a warning ("timed out, will retry at the end of the run"), not an error. A few live sites still end as `unreachable: timeout` (1.1 % of 360 listings in the last test). They are not charged: run them again.
- **Built-in statistics of hosted builders.** Wix, Squarespace, Duda, GoDaddy, Webador, Shopify and similar builders count visits themselves, so "no analytics" is not raised on them. Whether the owner reads those statistics cannot be known.
- **Sites that treat bots differently.** Some hosts answer automated requests with a firewall page or an endless stream (`blocked`), and some sites refuse visitors from other countries (`blocked: geo_blocked`). They are not charged. A proxy can help for firewalls, and a proxy in the right country for `geo_blocked`. Since release 0.4 the user agent no longer ends in "Bot", which some hosts and site builders punish: 4.2 % of listings drawn at random were `blocked` in the last test (0.3 % for this tool only), against 5 to 7 % with the former name.
- **Pages that are not the site** (parking, sale, suspension, waiting, maintenance, spam, error and challenge pages) are recognised by their patterns, 92 % of fresh ones in the last test (release 0.6). A new kind of such page is audited, and charged, until it is added; tell us in the Issues tab when you meet one. A site that looks like a waiting page (a few lines saying "coming soon") but is the real, minimal site of the business can be put in `placeholder`: it is free, so you lose nothing but its audit.
- **The outdated technology score does not see the design.** A site with a dated look on current technology scores 0. Sort by `healthScore` as well, and look at the sites yourself before you pitch a redesign.
- **Business names** come from the page (or from your input). A sole trader may trade under their own name. A tagline can stay attached to the name.
- **Cookie banners** loaded by a third-party script after the page is shown can be missed, so `TRACKERS_WITHOUT_CONSENT` is a warning only on European sites.
- **Performance** is a rough estimate without a PageSpeed key and is flagged as such.
- The certificate is always probed directly, even when a proxy is set. With a proxy, the name of a site is resolved once by the Actor, checked, and the proxy is asked for that address (the Host header and the TLS server name keep the name): a name cannot show a public address to the check and a private one to the proxy.
- **Pages too complex to read.** A page with more than 50,000 elements, or nested more than 500 levels deep, is not parsed: status `truncated`, reason `too_many_elements` or `too_deep`, not charged. The HTML of each page is read in a worker thread with its own memory and a time limit of 25 seconds; a page that goes past either gets status `blocked`, reason `analysis_timeout` or `analysis_failed`, not charged, and the other sites go on. At most the first 5,000 links of a page are read (the link counts are those of these links).
- **Time per site.** All the work on one site stops after 100 seconds (160 with a PageSpeed key): requests in flight are cut and none starts afterwards.
- **Restart of the run.** If the container restarts or migrates while the run is going, the run reads back the rows already written: no site is audited, written or charged again, and the rows for entries without a website or sharing a website are not written twice. `RUN_SUMMARY` is rebuilt from the rows.
- **Robots.txt.** The sitemap, the scripts of the site that are read for viewport, phone and statistics tags, the jQuery banner and every page beyond the home page are only requested where robots.txt allows. When robots.txt forbids everything (or could not be read), the answers that depend on them are "unknown" and no problem is claimed from them. The home page that you asked for, its phone version and robots.txt itself are always requested.

### FAQ

**Can it audit sites in other languages?** Yes, the checks work on any site; messages, summaries and pitch lines are in French or English (`auto` picks the language of the site, English for the others; a waiting page on a French domain gets French).

**Does it need a browser or proxies?** No. Proxies are optional.

**Is it allowed to read these sites?** It reads publicly available pages with an honest user agent that names the tool and links to its page (`WebsiteAudit`, announced as a phone for the requests that read the phone version of a page), fetches the home page you asked for, and respects robots.txt for every other page and file (contact page, sitemap, link checks, the scripts of the site, the jQuery banner), including rules written for its former name `WebsiteAuditBot`.

**Why is a site marked `placeholder`?** Its address only shows a page that says the site is under construction, coming soon or in maintenance, or the default page of a web server. There is nothing to audit, so it is free, like a business listed without a website: it is the same lead (the business has an address but no site). `statusReason` says which kind (`under_construction`, `maintenance_page`, `default_server_page`). A "coming soon" page that also shows the address and the contact details of the business stays a `placeholder` and is free; its pitch line then says that the contact details are on the page.

**What is `billed`?** A boolean on every row, `true` only for the rows that were charged. Count the rows with `billed: true` to know what a run cost.

**Why is a site marked `js_rendered_shell` and not charged?** Its HTML has no content (the page is built in the browser), so any audit of it would invent problems. Use a browser-based tool for those.

**Why did a site come back `unreachable` with `timeout`?** It did not answer within 15 seconds, and again within 30 seconds at the end of the run. Run it again later: nothing was charged.

**What do the warnings in the log mean?** A warning says that a site is tried again (a timeout, a DNS hiccup, a failed HTTPS connection) or could not be reached; its row comes out with its status either way. An error in the log means a fault of the Actor itself: please report it in the Issues tab.

**Why do I get a `duplicate` row?** Another entry of your input has the same website. Its audit is copied on the row with this entry's own name and IDs, so every listing keeps its row; it is not charged twice.

**Can I get the email or phone number of the business?** No. The Actor returns only booleans (`hasEmailVisible`, `hasPhoneVisible`), the company social page links, and the business name, city and category. It never stores personal contact data.

**How do I stop a run that costs too much?** Set a maximum charge per run in the Apify console, or `maxSites`.

### Legal and privacy

The Actor reads publicly available web pages and does not collect personal data: no email address, phone number or person's name is stored, and the first two are removed from every field of a row before it is written. The link to the personal profile of a person on Facebook (`facebook.com/first.last.123`, the number that Facebook adds to a name already taken, `profile.php?id=...`, `people/...`; a vanity name without a number cannot be told from the name of a business page and is kept) is not kept as `facebookUrl`. Note that a sole trader may trade under their own name, which then appears as the business name. You are responsible for how you use the results, including any outreach, under the rules that apply to you (GDPR, ePrivacy, CAN-SPAM, local law).

# Actor input Schema

## `startUrls` (type: `array`):

One entry per website, with a scheme: https://example.com. Only the homepage and a few key pages (contact, about) are visited. Duplicate domains are audited once (a final dot makes no difference: example.fr. is example.fr). Only the ports 80, 443, 8080 and 8443 are audited. Clear this example list if you use the fields below instead. For bare domains use the field "Domains". To get your own identifier back on each row, add it as userData.externalId.

## `domains` (type: `array`):

One bare domain per line, for example example.com or www.example.fr. Use this field to paste a column of domains from a spreadsheet.

## `datasetId` (type: `string`):

Pick an Apify dataset whose items have a "website" field, such as the output of a Google Maps scraper run (or paste its ID). In an integration (scraper run, then this Actor), use {{resource.defaultDatasetId}}. For each item the Actor audits the website and copies title, categoryName, city, placeId and the Google Maps URL to the result. Phone numbers, emails, ratings and reviews of the source are ignored and never copied. Items without a website come back with the status no_website, never charged.

## `businesses` (type: `array`):

Advanced: a list of objects like the items of a Google Maps scraper, for example \[{"website": "https://x.fr", "title": "X Plomberie", "categoryName": "Plumber", "city": "Lyon", "placeId": "ChIJ...", "externalId": "row-12"}]. Only website, title, categoryName, city, placeId, url (a Google Maps URL) and externalId are used.

## `maxSites` (type: `integer`):

Stop after this many distinct websites. Leave empty for no limit. The run also stops cleanly at your maximum cost per run: no row is charged beyond it. The entries read are capped at ten times this number (1,000 at least, 100,000 at most), the end of a longer input is ignored.

## `maxPagesPerSite` (type: `integer`):

1 to 5. The homepage is always audited; with 2 or more, the contact page and then about, legal and services pages are also read when found. Their contact and tracking signals are merged into the result. With 1, a phone number that cannot be tapped is only reported as information when the site has a contact page (that page is not read).

## `checkBrokenLinks` (type: `boolean`):

Check a sample of up to 10 internal links from the homepage (menu links first) with HEAD requests. Pages disallowed by robots.txt are skipped.

## `language` (type: `string`):

Language of issue messages, fixes, the technical summary and the pitch line. Auto uses the language of each site (French or English); a placeholder page on a French domain gets French; a listing without a website gets the language of its category.

## `pageSpeedApiKey` (type: `string`):

Your own Google PageSpeed Insights API key. When set, the mobile performance score is measured by Google instead of estimated. The key is stored as a secret and never logged.

## `proxyConfiguration` (type: `object`):

Off by default. Use it only for sites that block datacenter IP addresses. The TLS certificate check is always made directly.

## `maxConcurrency` (type: `integer`):

Sites audited in parallel (1 to 50).

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://example.com"
    }
  ],
  "domains": [
    "example.com",
    "plomberie-martin.fr"
  ],
  "datasetId": "aBcDeFgHiJkLmNoPq",
  "businesses": [
    {
      "website": "https://example.com",
      "title": "Example",
      "categoryName": "Plumber",
      "city": "Lyon",
      "placeId": "ChIJexample",
      "externalId": "row-1"
    }
  ],
  "maxSites": 100,
  "maxPagesPerSite": 2,
  "checkBrokenLinks": true,
  "language": "auto",
  "maxConcurrency": 10
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

## `problems` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://www.electricitesystemes.com/"
        },
        {
            "url": "https://www.violetsalonspa.com/"
        },
        {
            "url": "https://capitalcityapplianceservice.com/"
        },
        {
            "url": "https://www.brackleycobblers.com/"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("maxbuildog/website-audit-seo-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "startUrls": [
        { "url": "https://www.electricitesystemes.com/" },
        { "url": "https://www.violetsalonspa.com/" },
        { "url": "https://capitalcityapplianceservice.com/" },
        { "url": "https://www.brackleycobblers.com/" },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("maxbuildog/website-audit-seo-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://www.electricitesystemes.com/"
    },
    {
      "url": "https://www.violetsalonspa.com/"
    },
    {
      "url": "https://capitalcityapplianceservice.com/"
    },
    {
      "url": "https://www.brackleycobblers.com/"
    }
  ]
}' |
apify call maxbuildog/website-audit-seo-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,maxbuildog/website-audit-seo-checker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/mHzgVWz2htkfbgJLH/builds/MyNfuKgSoIwvseUMa/openapi.json
