Quebec Bill 96 French Website Compliance Auditor avatar

Quebec Bill 96 French Website Compliance Auditor

Pricing

from $100.00 / 1,000 page pair compareds

Go to Apify Store
Quebec Bill 96 French Website Compliance Auditor

Quebec Bill 96 French Website Compliance Auditor

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.

Pricing

from $100.00 / 1,000 page pair compareds

Rating

0.0

(0)

Developer

Barak Eliov

Barak Eliov

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

a month ago

Last modified

Categories

Security

Limited permissions

Share

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

{
"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):

{
"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):

{
"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):

{
"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)

CategoryWhen
invalid_urlMalformed 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_foundDNS failure, connection refused, 404/410
blockedHTTP 401/403/429 on either language version. Never conflated with a missing-French-content finding - see below
pair_discovery_failedNo 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
timeoutNavigation/render exceeded timeoutSecs on either side
render_errorBrowser crash, an unclassified render failure, or a 5xx upstream response
comparison_failedBoth 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.

  • 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

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.