# Quebec Bill 96 French Website Compliance Auditor (`elab/quebec-bill96-french-website-compliance-auditor`) Actor

Batch-audits EN/FR page pairs on business websites for Quebec Bill 96 French-language compliance. No login required - checks a list of URLs you supply, including sites you audit on behalf of clients.

- **URL**: https://apify.com/elab/quebec-bill96-french-website-compliance-auditor.md
- **Developed by:** [Barak Eliov](https://apify.com/elab) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $100.00 / 1,000 page pair compareds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## Quebec Bill 96 French Website Compliance Auditor

Batch-audits a list of business websites for Quebec Bill 96 (Charter of the French Language, CQLR c.
C-11) French-content parity. For each target you supply one URL (in either language) - this Actor
discovers its paired language version via `<link rel="alternate" hreflang>` tags or an in-page
language-switcher link, renders both pages in a real headless browser, and uses an LLM to compare them
for semantic completeness (not a word-for-word diff - two independently-written language versions are
never identical).

No login, no install on the audited site - built for a law firm, translation agency, or franchise/retail
chain auditing a LIST of OTHER businesses' or franchise locations' public websites in one run.

### What it returns

1. **Pair discovery, never guessed.** Hreflang tags first, in-page switcher-link text as fallback. If
   neither signal resolves a pair, the target is reported `discoveryMethod: "failed"` and **not
   charged** - this Actor never assumes a URL pattern like `/en` → `/fr` (confirmed to fail on real sites
   with non-standard schemes, e.g. `/fr-ca` instead of `/fr`).
2. **LLM-based semantic completeness comparison**, not a textual diff: a 0-100 completeness estimate plus
   discrete findings (`missing_content`, `partial_translation`, `functional_gap`, `pricing_gap`,
   `navigation_gap`, `english_only_ui_text`).
3. **Screenshot evidence** - one full-page screenshot per language version, bundled into the price.
4. **The single most important correctness rule in this Actor**: a page that is bot-blocked, 404s, times
   out, or errors is categorized as a **fetch failure** (`blocked`/`not_found`/`timeout`/`render_error`),
   never charged, and never reported as "French content is missing." A blocked page is evidence of
   nothing about compliance - the dataset row and the PDF report both say so explicitly.
5. **Optional PDF compliance report** - completeness scores, every finding, screenshots, the Charter
   s.52 obligation, the s.205/207/208/208.1 fine ladder, and a real cited enforcement case.
6. **URL safety** - every URL (the one you supply AND any discovered/pinned paired URL) is checked
   against private, loopback, and cloud-metadata address ranges before the browser opens it.
7. **No charge for a failed/blocked/discovery-failed/comparison-failed pair.** Only a pair that was
   actually fetched and actually compared is charged.

### Input example

```json
{
  "targets": [
    { "url": "https://www.jeancoutu.com/en", "label": "head-office" },
    { "url": "https://garaga.example/nonstandard/ca", "pairedUrl": "https://garaga.example/nonstandard/fr-ca", "label": "non-standard-scheme example" }
  ],
  "autoDiscoverPair": true,
  "comparisonScope": "mainContentOnly",
  "generateComplianceReport": true
}
```

Full input fields (all have defaults; see `.actor/input_schema.json` for the authoritative list):
`targets`, `autoDiscoverPair`, `englishLocaleHints`, `frenchLocaleHints`, `comparisonScope`,
`includeScreenshots`, `generateComplianceReport`, `businessName`, `waitForSelector`, `timeoutSecs`,
`proxyConfiguration`, `maxConcurrency`.

**Use `pairedUrl`** whenever you already know a site's paired-language URL, especially on a non-standard
scheme (e.g. `/fr-ca` instead of `/fr`) - it skips discovery entirely and guarantees a correct match.

### Output example

This dataset has three row shapes, distinguished by `rowType`.

**Pair row** (one per target):

```json
{
  "rowType": "pair",
  "requestedUrl": "https://www.jeancoutu.com/en",
  "label": "head-office",
  "resolvedEnUrl": "https://www.jeancoutu.com/en",
  "resolvedFrUrl": "https://www.jeancoutu.com/fr",
  "discoveryMethod": "hreflang",
  "status": "success",
  "errorCategory": null,
  "errorMessage": null,
  "completenessScorePct": 92,
  "findingCount": 1,
  "findingsByCategory": { "missing_content": 0, "partial_translation": 1, "functional_gap": 0, "pricing_gap": 0, "navigation_gap": 0, "english_only_ui_text": 0 },
  "screenshotUrlEn": "https://api.apify.com/v2/key-value-stores/.../records/en-....png",
  "screenshotUrlFr": "https://api.apify.com/v2/key-value-stores/.../records/fr-....png",
  "waitForSelectorTimedOut": false,
  "comparisonModel": "claude-haiku-4-5-20251001",
  "auditedAt": "2026-10-09T19:00:00.000Z"
}
```

**Finding row** (one per individual content-parity issue, on a successfully-compared pair):

```json
{
  "rowType": "finding",
  "pairUrl": "https://www.jeancoutu.com/en",
  "category": "partial_translation",
  "description": "The seasonal promotions section is abbreviated in French compared to the English version.",
  "severityGuess": "medium",
  "sourceSnippetEn": "Save up to 30% on select seasonal items through the end of the month...",
  "sourceSnippetFr": "Profitez de rabais...",
  "screenshotUrl": null
}
```

**Summary row** (exactly one per run, also saved to the key-value store as `batchSummary`):

```json
{
  "rowType": "summary",
  "totalPairsAudited": 1,
  "totalFindings": 1,
  "findingsByCategory": { "missing_content": 0, "partial_translation": 1, "functional_gap": 0, "pricing_gap": 0, "navigation_gap": 0, "english_only_ui_text": 0 },
  "findingsBySeverity": { "high": 0, "medium": 1, "low": 0 },
  "avgCompletenessScorePct": 92,
  "complianceReportUrl": "https://api.apify.com/v2/key-value-stores/.../records/compliance-report-....pdf",
  "charterCitation": {
    "article52": "Charter of the French Language, CQLR c. C-11, s.52 — commercial publications, including website content, must be drawn up in French.",
    "enforcementMechanism": "A complaint or OQLF-initiated investigation can lead to a compliance order under s.177; failure to comply with that order is an offence under s.205.",
    "fineLadder": {
      "firstOffence": "$3,000-$30,000 (legal person)",
      "secondOffence": "$6,000-$60,000 (doubled, s.207)",
      "thirdOffenceAndBeyond": "$9,000-$90,000 (tripled, s.207)",
      "perDay": "each day a violation continues is a separate offence, s.208.1",
      "directorOfficer": "double the natural-person range if personally liable, s.208"
    },
    "citedCase": "8540527 Canada inc. (Comac Corporation): $3,000 fine, Cour du Québec, March 26 2026; infraction detected by OQLF May 15 2024; published by OQLF May 15 2026.",
    "framing": "This is a long-standing, actively-enforced obligation (s.52 predates Bill 96), not a one-time 2025/2026 deadline.",
    "disclaimer": "This report is an automated content-comparison scan, not a legal opinion or an OQLF determination."
  }
}
```

A failed pair's row has `status: "error"`, `errorCategory` set to one of `invalid_url` / `not_found` /
`blocked` / `pair_discovery_failed` / `timeout` / `render_error` / `comparison_failed`, and every
audit-specific field left `null`. **Failed pair rows are never charged.**

### Errors (never silent, never charged)

| Category | When |
|---|---|
| `invalid_url` | Malformed URL, non-http(s) URL, or the URL (requested, `pairedUrl`, or discovered) resolves to a private/link-local/loopback/cloud-metadata IP address (SSRF protection, always on) |
| `not_found` | DNS failure, connection refused, 404/410 |
| `blocked` | HTTP 401/403/429 on either language version. **Never conflated with a missing-French-content finding** - see below |
| `pair_discovery_failed` | No `pairedUrl` supplied, and no hreflang alternate or switcher link resolved a pair (or `autoDiscoverPair` is false and no `pairedUrl` was given); also used when a pair was found but which side is English vs. French could not be confidently determined |
| `timeout` | Navigation/render exceeded `timeoutSecs` on either side |
| `render_error` | Browser crash, an unclassified render failure, or a 5xx upstream response |
| `comparison_failed` | Both pages were fetched successfully but the LLM comparison call failed or returned a malformed/unusable response, even after one internal retry |

#### The one rule that matters most: blocked ≠ missing

If the French version of a pair is bot-blocked, times out, or 404s while the English version succeeds
(or vice versa), this is reported as a **fetch failure** - `status: "error"`, the matching
`errorCategory`, and an `errorMessage` that explicitly says "this is a fetch failure, not a finding about
missing or incomplete French content." It is never charged, and the PDF report's banner for that pair
says "FETCH FAILURE — NOT A COMPLIANCE FINDING" rather than implying anything about French content. A
false "non-compliant" conclusion caused by this Actor's own inability to fetch a page would be a serious,
unacceptable error against a real business.

A `waitForSelector` that never appears is **not** an error: the pair is still compared and
`waitForSelectorTimedOut: true` is reported (charged normally - a real comparison was delivered).

### Pair discovery

1. **`pairedUrl` supplied** → used as-is, discovery skipped entirely. Recommended for any site you've
   already confirmed uses a non-standard scheme.
2. **Hreflang tags** → `<link rel="alternate" hreflang="...">` on the rendered page.
3. **In-page language-switcher link** → anchor text matched against `englishLocaleHints`/
   `frenchLocaleHints` (e.g. "FR", "Français", "EN", "English").
4. **Never** a guessed URL pattern (e.g. assuming `/en` → `/fr`) - confirmed live to fail on real sites
   (a real EN/FR pair can live at `/fr-ca`/`/ca`, with no `/en`/`/fr` path at all).

Which side is English and which is French is determined from each page's own `<html lang>` attribute
(cross-checked against the discovery signals). If this cannot be determined with confidence on either
side, the pair is reported `pair_discovery_failed` rather than guessed - mislabeling the sides would
silently invert this Actor's entire compliance conclusion, which is worse than failing closed.

### The LLM comparison

This Actor's core logic calls an LLM (Anthropic's API) to compare the two pages' extracted text for
semantic completeness - never a textual diff, since two independently-written language versions are
never word-for-word identical by nature. The API key is read **only** from the `ANTHROPIC_API_KEY`
environment variable - never a user-supplied credential, and never committed to any file. A malformed or
unusable LLM response is retried once, then reported `comparison_failed` (not charged) - this Actor never
fabricates a finding from a response it couldn't parse.

`completenessScorePct` and `severityGuess` are both explicitly heuristic signals from the LLM, not a
legal determination or an official OQLF finding - stated as such in both the dataset and the PDF report.

### Screenshots and the PDF compliance report

`includeScreenshots` (default on) captures one full-page PNG per language version, bundled into the
`page-pair-compared` price.

`generateComplianceReport: true` produces one PDF per run: every pair's completeness score and findings,
screenshots, the Charter s.52 obligation, the fine ladder, a real cited enforcement case, and the
corrected "long-standing obligation, not a 2025/2026 deadline" framing. Failed pairs are listed with an
explicit fetch-failure banner - **never silently dropped from the report, and never conflated with a
compliance finding.** Charged as a separate `compliance-report-generated` event, and **only when it
actually succeeds** - if every pair in the batch failed, generation is skipped and nothing is charged.

A per-pair and batch-wide cap bounds how many findings are embedded in full detail in the PDF (cost/size
safety, same pattern as this factory's other report-generating Actors); anything held back is disclosed
via a count in the report text, never silently dropped - every finding is still a full row in the
dataset regardless.

### Legal citation (read before citing this report)

- s.52 of the Charter of the French Language is a **long-standing obligation that predates Bill 96** -
  not a new 2025/2026 deadline. The June 2025/2026 dates in Bill 96 coverage apply to other, unrelated
  provisions.
- The fine ladder and the cited enforcement case (`charterCitation` in the summary row) were
  independently verified against `legisquebec.gouv.qc.ca` and a real published OQLF enforcement case.
- This Actor cites only article/section **numbers** plus its own short paraphrase - **never verbatim
  Charter or Gazette officielle text.**
- **This report is an automated content-comparison scan, not a legal opinion or an official OQLF
  determination.**

### Limits

- Max 100 targets per run (internal safety cap; no `maxItems` input field in v1).
- Max 120s `timeoutSecs`, minimum 5s, applied independently to each side's navigation.
- Extracted page text sent to the LLM is capped at 8,000 characters per side (cost/reliability safety
  limit) - a truncation note is logged when this triggers; it does not affect which language version was
  compared, only how much of a very long page's text reaches the LLM call.
- This Actor audits exactly the targets you list. It does not crawl a domain to discover every bilingual
  page pair on its own.
- Pair discovery relies on hreflang tags or an in-page switcher link. A site with neither (no hreflang,
  no visible language-switcher) will be reported `pair_discovery_failed` - supply `pairedUrl` manually.

### Use with AI agents / MCP

Call the Actor with `targets` as an array of objects (never a comma-separated string). Check `rowType`
first, then `status`/`errorCategory` on pair rows. Only `status: "success"` pair rows have populated
`completenessScorePct`/`findingCount`. Filter `rowType: "finding"` rows by `pairUrl` to group findings per
target. The single `rowType: "summary"` row gives you a batch-level answer, including the static legal
citation, without having to aggregate pair rows yourself.

### FAQ

**Am I charged for a blocked, failed, or discovery-failed pair?** No. Only a pair that was actually
fetched and actually compared by the LLM is charged.

**If a pair is reported `blocked`, does that mean the site isn't Bill 96 compliant?** No - it means this
Actor couldn't fetch the page. No compliance conclusion can be drawn until the page is reachable.

**Is `completenessScorePct` an official compliance score?** No - it is this Actor's own LLM-derived
estimate, stated as a heuristic signal, not a legal determination.

**Can this bypass CAPTCHAs or logins?** No. It audits publicly-rendered pages only.

**Does this store or require my own OpenAI/Anthropic API key?** No. The LLM credential is a build-time
environment variable on this Actor's own infrastructure - you never supply one.

### Not supported yet

Automatic site-wide crawl/discovery of every bilingual page pair on a domain, scheduled recurring
re-audits, and a standalone "reverse" FR→EN completeness mode as a headline feature (this Actor already
checks both directions internally as a QA signal, but French completeness is the primary output).

### Local development

```bash
npm install
npx playwright install chromium   # one-time, downloads a browser to the shared Playwright cache
npm run typecheck
npm test
npm run build
ANTHROPIC_API_KEY=sk-... apify run --input-file smoke-input.json --purge
```

Without `ANTHROPIC_API_KEY` set, every pair will correctly report `comparison_failed` (fetch/discovery
still runs normally) rather than crashing - useful for testing the fetch/discovery pipeline without an
LLM credential.

### Known limitations

- Some real sites run bot-protection that blocks automated traffic outright (confirmed live during this
  build's stress test, e.g. `rona.ca`, `uniprix.com`) - these are correctly reported as `blocked` and not
  charged, but cannot be audited at all until the site's protection allows it.
- Pair-role labeling (which side is English vs. French) relies on each page's own `<html lang>`
  attribute. A site that omits it on both pages, with no other usable signal, will fail closed
  (`pair_discovery_failed`) rather than guess.

# Actor input Schema

## `targets` (type: `array`):

Pages to audit for Quebec Bill 96 French-language parity. For each target, supply at least 'url' (the page you have, in either language) - this Actor auto-discovers its paired language version via hreflang tags and in-page language-switcher links. If you already know the exact EN/FR pair (recommended for sites with non-standard URL schemes, e.g. '/fr-ca' instead of '/fr'), supply 'pairedUrl' directly to skip discovery and guarantee a correct match. 'label' is optional and purely cosmetic (e.g. a franchise location name) for the report.

## `autoDiscoverPair` (type: `boolean`):

When true, auto-discover a target's missing language counterpart via hreflang/language-switcher detection if 'pairedUrl' isn't supplied. When false, any target without an explicit 'pairedUrl' is reported as 'pair_discovery_failed' rather than guessed.

## `englishLocaleHints` (type: `array`):

lang/hreflang codes and link-text patterns treated as English during pair discovery.

## `frenchLocaleHints` (type: `array`):

lang/hreflang codes and link-text patterns (e.g. 'Français', 'FR') treated as French during pair discovery.

## `comparisonScope` (type: `string`):

'mainContentOnly' compares the primary content area (product info, body copy, pricing) and ignores shared chrome (nav/footer boilerplate) to keep comparisons focused and cost-efficient. 'fullPage' compares everything, including navigation and footer text, useful when checking UI/menu translation completeness specifically.

## `includeScreenshots` (type: `boolean`):

Capture a full-page screenshot of both the English and French versions of each audited target, for use as evidence in the compliance report. Bundled into the 'page-pair-compared' price - no extra charge.

## `generateComplianceReport` (type: `boolean`):

Generate one Bill 96 compliance PDF report per run: completeness scores, every finding, screenshots, the Charter s.52 content obligation, the s.205/207/208/208.1 fine ladder, and a real cited enforcement case. Charged separately as a 'compliance-report-generated' event, and only when it actually succeeds - never charged if zero pairs were compared successfully.

## `businessName` (type: `string`):

Optional business or franchise-location name to print on the report's cover page. Purely cosmetic.

## `waitForSelector` (type: `string`):

Optional CSS selector to wait for (on both the English and French pages) before comparing - useful for SPAs/client-rendered pages. If it never appears within timeoutSecs, the pair is still compared and 'waitForSelectorTimedOut' is set true rather than failing.

## `timeoutSecs` (type: `integer`):

Max seconds for navigation/render per page (applied independently to each of the two pages in a pair) before that side is reported as a timeout error.

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

Apify Proxy configuration for the audit requests.

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

Maximum target pairs processed in parallel within one run.

## Actor input object example

```json
{
  "targets": [
    {
      "url": "https://www.jeancoutu.com/en",
      "label": "smoke-test-clean-pair"
    }
  ],
  "autoDiscoverPair": true,
  "englishLocaleHints": [
    "en",
    "en-ca",
    "en-us"
  ],
  "frenchLocaleHints": [
    "fr",
    "fr-ca"
  ],
  "comparisonScope": "mainContentOnly",
  "includeScreenshots": true,
  "generateComplianceReport": true,
  "businessName": "",
  "waitForSelector": "",
  "timeoutSecs": 30,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

One row per audited target (rowType "pair"), one row per individual content-parity finding (rowType "finding"), and exactly one batch rollup row (rowType "summary") including the static Charter of the French Language citation (article number, fine ladder, a real cited enforcement case).

## `files` (type: `string`):

Full-page PNG screenshots of the English and French versions of each pair (referenced by screenshotUrlEn/screenshotUrlFr on pair rows), the 'batchSummary' record, and the PDF compliance report (when generateComplianceReport is true) referenced by complianceReportUrl.

# 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 = {
    "targets": [
        {
            "url": "https://www.jeancoutu.com/en",
            "label": "smoke-test-clean-pair"
        }
    ],
    "englishLocaleHints": [
        "en",
        "en-ca",
        "en-us"
    ],
    "frenchLocaleHints": [
        "fr",
        "fr-ca"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("elab/quebec-bill96-french-website-compliance-auditor").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 = {
    "targets": [{
            "url": "https://www.jeancoutu.com/en",
            "label": "smoke-test-clean-pair",
        }],
    "englishLocaleHints": [
        "en",
        "en-ca",
        "en-us",
    ],
    "frenchLocaleHints": [
        "fr",
        "fr-ca",
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("elab/quebec-bill96-french-website-compliance-auditor").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 '{
  "targets": [
    {
      "url": "https://www.jeancoutu.com/en",
      "label": "smoke-test-clean-pair"
    }
  ],
  "englishLocaleHints": [
    "en",
    "en-ca",
    "en-us"
  ],
  "frenchLocaleHints": [
    "fr",
    "fr-ca"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call elab/quebec-bill96-french-website-compliance-auditor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,elab/quebec-bill96-french-website-compliance-auditor"
        }
    }
}
```

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/35UialfUlLh1vdqKC/builds/2YMnPEk51yf6OMdGc/openapi.json
