Skip to main content
langaccess

Python Code Bundled model

langaccess audits the language accessibility of a website. Given a URL, the tool renders the site in a headless browser and classifies, for each language present, the form of access provided: English-only content, machine translation, or text authored by the site itself. The result is a classification and a per-language breakdown, and each finding is returned with the URL and quoted text that support it, so that any result can be verified without repeating the crawl. The tool describes and classifies what a site provides; it does not score how well a site serves any population.

How a result is produced. A website address, with robots.txt respected, is read two ways: rendered in a headless Chromium, and fetched again as the plain server document. The difference between the two separates widget text from the site's own writing, the codebook rules judge each language on authorship and extent, and three things come out: a classification, an evidence record with the URL and quoted text, and a read-quality record.

langaccess is a wrapper around two libraries. Playwright runs a Chromium that renders each page and hands back its text, and fastText's lid.176 names the language of a passage the package's own rules do not cover. There is no third-party HTML parser in the reading path: the text comes from the rendered DOM rather than from source markup, since a translation widget rewrites the DOM and leaves the markup alone.

The package supplies the crawl and the judgement: which pages to open and in what order, the language controls to work on the page, the separation of translation-widget output from text the site wrote, the length and script thresholds a finding has to clear, and the record of what the search was worth, so that the absence of a language can be told apart from a failure to look for it. lid.176 is redistributed under CC BY-SA 3.0 and its licence text ships with the wheel.

1. Installation

pip install langaccess
python -m playwright install chromium

Python 3.10 or newer is required. The second command is required on a machine that has not previously installed Playwright's browsers. The tool reads sites in a headless browser: an installed Google Chrome is used where present, with the Chromium that Playwright manages as the fallback. Where neither is present, audit raises BrowserUnavailable and the command line stops, prints the reason once on stderr, and exits with code 3 rather than reporting every site as unreachable. Three extras are available: langaccess[web] adds the FastAPI front end under web/, langaccess[frame] adds pandas for to_frame, and langaccess[test] adds the test dependencies.

1.1 First run

A fresh install has no address list yet, so start with langaccess demo. It judges four invented sites that ship inside the package, from pages stored with them: no browser starts and no address is fetched, and what it prints is the instrument's own output rather than a transcript. The four are one each of english_only, machine_translate, true_multilingual and unreachable. The fifth class, machine_translate_error, needs a control to be clicked, which a stored capture cannot carry, so no demo can show it.

langaccess demo

To check that a browser, the model and the crawl work on this machine, calibrate --demo reads one real address, the author's own site. One address settles no setting.

The demo target carries a Google Translate widget offering eleven languages, and the author's own Korean writing on its archive pages. The probe reports timing and depth and discards its results; the same address read with the main command shows the fields a run produces.

langaccess calibrate --demo        # probe the demo address, print settings for this machine
langaccess https://nariyoo.com     # the same address, read for its verdict

https://nariyoo.com/
  verdict   machine_translate
  evidence  authorship authored   sufficiency 0 none
  languages English
    English          authored       4 section
    Korean           authored       0 none
  widget    Google Translate   pages read 15
  search    enough to rest an absence claim on  (budget_exhausted)
  switcher  offers 11: Arabic, Chinese, Hindi, Japanese, Korean, Nepali, Portuguese, Spanish, Tagalog, Thai, Vietnamese
  rules     12 a named language for true_multilingual; 13 archive and past-event pages; 14 an installed widget with no visible control

Under the rules, the command also prints each piece of evidence as its mechanism, its language and the words that produced it. Section 2.4 shows that field on an address of your own.

The menu of eleven entries is recorded as the widget's offer, under the vendor's name, and widget output is not text the site wrote; the Korean the site does carry is on archive pages, which rule 13 leaves outside the reading.

Before a long run, measure the settings on your own list with langaccess calibrate --from-file sites.txt -n 20, and read the result against section 12.2 of docs/USAGE.md.

2. Quickstart

2.1 One address

from langaccess import audit

r = audit('https://example.org')
r.verdict             # 'english_only' | 'machine_translate' | 'machine_translate_error'
                      #  | 'true_multilingual' | 'unreachable'
r.languages           # ['Chinese', 'English', 'Spanish']
r.by_language         # {'Spanish': {'authorship': 'authored', 'sufficiency': 3}, ...}
r.switcher_languages  # ['French', 'Spanish', 'Vietnamese']
r.read_quality        # {'pages_read': 15, 'sufficient': True, 'escalated': False, ...}
r.evidence            # [Evidence(mechanism='inline_text', url='...', quote='...'), ...]

languages lists the languages the classification counted, English among them. Where English is absent, the site is written only in another language.

by_language reports each language separately, so a site with Spanish the organization published and Vietnamese a widget produced shows one result of each.

switcher_languages lists what the page's language menu offers, a question separate from the languages the site is written in. On a site carrying a widget the list is that widget's offer, and machine_translation names the vendor, so a site offering two hundred machine-translated languages and a site offering four are recorded distinctly.

read_quality reports how much reading the classification rests on: pages read, the stop reason, and whether the search was deep enough for a claim that a language is absent.

declared_off_site reports where the page's declared alternates pointed: how many of them give an address on another site, and which languages no alternate on this one named. An organization publishing its Spanish on a second domain of its own and an address that has lapsed and now serves somebody else look the same in a document, so the language is reported either way and review puts the ambiguous results in front of a person.

2.2 Many addresses

audit_many reads a list of addresses through one browser instance and gives every site its own browser context, so one site's cookies, cache and translation-widget state do not reach the next site. Passing store= writes every page the crawl read to a capture file.

from langaccess import audit_many

results = audit_many(urls, concurrency=4, timeout=120, store='run.jsonl')

Results come back in the order the addresses were given. A site that times out or fails is returned as a Result carrying the failure in its note, and the rest of the list continues.

2.3 Stored captures

A capture taken with store= is judged again with no network access, which is how a rule change is checked over a whole run in seconds and how a validation sample is coded twice against one capture of each site.

from langaccess import rejudge, rejudge_store

r = rejudge('run.jsonl', 'https://example.org/')
r.verdict          # judged over the stored pages
r.unreproducible   # the steps of a live audit a stored capture cannot carry

all_results = rejudge_store('run.jsonl')

Re-judgement calls the same functions the live audit calls, so a stored capture and a live audit cannot diverge in their judgement logic.

A store holds the full HTML of pages other people wrote. It exists so a result can be re-checked; publishing the file itself redistributes entire websites and is not covered by this package's licence or practice. Publish results, counts and short quoted passages with their addresses, and keep the store private.

2.4 Explanation

explain arranges what a result recorded into the working behind its classification: the numbered rules that fired, in the stages the package applies them in, the evidence each one rests on with its address and quoted words, the two axes per language, and how much reading the classification rests on. It judges nothing itself.

from langaccess import explain, explain_text

print(explain_text(r))
x = explain(r)     # the same arrangement as a dict
x['rules']['tested_not_fired']   # the one negative finding the record supports

A rule absent from a result has not been shown not to apply, so the rules are reported in five states and only the rules verdict_rules asks on every call are reported as tested and not fired. explain accepts a live Result, a re-judged one, and the dict a stored run holds.

2.5 Run comparison

diff_runs compares two runs over the same addresses and reports what moved per site: verdict changes, languages gained and lost, and authorship changes.

from langaccess import diff_runs, diff_text

d = diff_runs('before.jsonl', 'after.jsonl')
print(diff_text(d))
d['unreachable']['toward']   # the sites that stopped being readable, reported first and on their own
d['sites']['only_in_a']      # addresses in one run and not the other, counted and named
d['moved']                   # every site that changed, with both results of each

Movement toward unreachable is never netted against the rest, since a change that makes sites unreadable would otherwise hide inside a favourable total, and an address present in one run and absent from the other is never dropped.

2.6 Hand-coding queue

Three states a class does not settle: A site recorded unreachable was not read at all, and an english_only verdict whose read_quality reports sufficient: False rests on a search the package will not stand behind. review hands those results out as a work queue instead of publishing them as verdicts, and ingest reads the finished sheet back. Each queued reading is named by what is unsettled about it and carries the sentence a coder has to act on.

langaccess review run.jsonl -o review.csv     # one row per site a person has to decide
langaccess ingest review.csv run.jsonl        # the filled sheet, back into the run
from langaccess import needs_human, contested, review_queue, ingest_review, hand_coding

needs_human(r)                  # unreachable, a thin absence claim, an unnameable translation
                                # control, or no class at all
contested(r)                    # settled, and shaped like the boundaries the model coders split over
q = review_queue('run.jsonl')
q['records'], q['unsettled']    # what was scanned, beside what needs a person
q['contested']                  # settled results a second look is worth, counted apart
records, report = ingest_review('review.csv', 'run.jsonl')

contested flags settled results shaped like the boundaries the model coders split over. No figure is attached to the flag: what it offers a study is a place to spend a coder's time and not a measured correction, and it moves no result.

Each row carries what a coder needs in order to decide without leaving the sheet: the address, the class reached, why it is unsettled as a sentence, when it was read, how many pages the reading rests on and the stop reason, the languages found, the language menu with the count of entries this package cannot name, and the quoted words behind each finding.

A human verdict wins over the machine's and is recorded as one, as a piece of evidence whose mechanism is hand_coding, carrying the verdict it replaced and the coder and date where the sheet supplies them, so a later reader can ask what share of a figure came from a person. A sheet naming an address the run does not hold is rejected whole and every fault is named. A queue that finds nothing says so, and exits non-zero on request.

2.7 Site report

report renders one result as one document for the organization whose site was read, the only output of this package addressed outside the project. It carries the classification with a sentence saying what that class means, the languages one at a time, every quotation with the address it was read at, the numbered rules that decided it, what the search covered and what it did not, and the date and the version.

langaccess report run.jsonl https://example.org -o example.html
langaccess report run.jsonl --all --dir reports/
from langaccess import report, report_text, write_report

print(report_text(r))
write_report(r, 'example.html')   # one HTML file, fetching nothing from anywhere

The document states what it is not, in full and in both forms: the package makes no determination of compliance with any federal or state law, with any regulation made under one, or with any professional guidance on interpretation, and holds no threshold at which a site becomes adequate. It does not judge translation quality, a website is not a service, an absence covers the pages the search read and nothing else, and a result is not carried forward. A record that never held a result raises NothingToReport rather than rendering a page of headings over blanks.

2.8 Command line

langaccess https://example.org
langaccess --deep --timeout 240 https://example.org
langaccess --json --output out.jsonl --from-file sites.txt
langaccess --explain https://example.org
langaccess calibrate --from-file sites.txt -n 20
langaccess diff run_a.jsonl run_b.jsonl
langaccess review run.jsonl -o review.csv
langaccess ingest review.csv run.jsonl
langaccess report run.jsonl https://example.org -o example.html
langaccess depth capture.jsonl
langaccess retry run.jsonl --your-browser -o retried.jsonl

depth reports how far each language extends into the pages a capture holds and retry re-opens the unreachable rows through a browser you are driving yourself; both are in docs/USAGE.md, sections 17 and 18.

3. Classes

english_only. No non-English text was found on the routes the crawl follows. The claim is bounded by those routes, and the bound is recorded in read_quality on every result.

machine_translate. The site offers a translation widget and no non-English text was found outside the widget's output. Widget output is not the site's own text and is unavailable whenever the widget does not load, so it is reported apart from the class below.

machine_translate_error. The site offers a translation widget, a control on it was operated, and the page did not change. What that establishes is that this automated browser could not obtain a translation through the widget on that date. It is held apart from english_only because english_only asserts that no other language was found, which was not established here; a widget can work in one browser and not another, and one site in this class translated for a visitor on a phone. Treat it as a site to open by hand: review puts every one of them in the hand-coding queue. No accuracy figure covers this class, for the reason given in LIMITATIONS.md.

true_multilingual. Non-English text is present in the document the server sent, so no browser-side widget produced it. The text may be the site's own or a server-side translation plugin's, and the authorship field on each piece of evidence records which was observed.

unreachable is a read outcome; it does not describe a form of access. The site was not read at all, because of a bot wall, a timeout, a parked domain, or an empty response, so nothing is established about its languages in either direction.

The five names are nominal. They are printed in a fixed order so that tables from different runs line up, and that order is not a ranking: no class is better than another, none is a step toward another, and a table sorted by class is not sorted by anything about the organizations. Scoring them 0 to 4 and entering that in a model is a misuse: english_only and machine_translate are on the same rung of the sufficiency axis and differ only on whether a widget was found, and unreachable is not a level of anything. The authorship values ARE ordered, by how directly the organization produced the text, which says nothing about how well anyone is served.

3.1 Codebook

Every result names the numbered rules that decided it, in Result.rules. RULES is the registry those numbers index, and each entry carries the rule's heading, the test it applies, and the objects in langaccess.core that apply it, so a number printed in a table of results can be resolved without the coding document this distribution does not carry. The paper gives the argument for each rule.

The numbers run 1 to 17 in the order the pipeline applies them, so the table can be read down the page in the order a crawl asks each question. The stage column carries that order for reading and can be renamed. The numbers cannot: a stored result and the coded standard both hold them as integers.

# stage rule criterion
1 Site identity social media profiles a Facebook, Instagram, LinkedIn, X/Twitter, YouTube, TikTok or Threads address is excluded; a site-builder address is not
2 Site identity registrar parking pages a registrar sales page, an expired-domain notice or an under-construction placeholder is unreachable, as a bot wall is
3 Pages in the reading rendered pages as the unit a downloadable document or an off-site form is not the site; what a visitor reads in the browser is
4 Pages in the reading two clicks from the home page the home page, a page linked from it, and a page linked from one of those
5 Pages in the reading pages still in service, whatever their date a page still served counts whatever its date; the archive shapes of rule 13 are the exception
6 Text against label a paragraph of connected prose four distinct function words inside one 500-character window of connected prose, so a tagline, a menu label or a list of titles does not clear it
7 Text against label the paragraph standard in every script rule 6 in any writing system, with the character count set per script
8 Text against label names of organizations, places and people an organization, place or personal name is evidence of no language, in either direction
9 Text against label a bilingual line with a verb a bilingual line with a verb meets the paragraph standard, and a verbless label does not
10 Evidence and its rung authored text beside a widget text a widget could not have produced counts against it, at the rung the page it sits on earns
11 Evidence and its rung a plugin marker without content a plugin marker counts only beside non-English content, never alone
12 Evidence and its rung a named language for true_multilingual true_multilingual only where the language can be named
13 Evidence and its rung archive and past-event pages a past-event write-up, a newsletter, a gallery caption or an index of old posts carries no reading
14 Site class an installed widget with no visible control a widget installed with no control rendering still classes the site machine_translate
15 Site class an advertised locale route in English a locale route the site itself advertises that returns the English page classes the site english_only; the server answered, whatever the browser
16 Site class a worked control without effect a control of a named widget that was operated and changed nothing classes the site machine_translate_error: the client failed to obtain a translation, and no absence is asserted. With no vendor named there is no widget to have failed, and the reading stands on what else was found
17 Site class five locale mirrors without a vendor marker five or more mirrored locale routes with no vendor marker read as machine translation; four do not

rule_titles(r.rules) turns the numbers into these titles, and the command line prints both. Rule 12 is a scoring rule with no object behind it: a language the instrument cannot name counts against it rather than leaving the denominator.

4. Scope

langaccess classifies the form of language access a website presents. For each language it finds, the tool records whether the text was authored by the site, produced by a translation widget, or absent, and returns the URL and the quoted words behind that finding.

The tool does not evaluate whether a service meets a legal or professional standard. A classification is not a determination of compliance with any federal or state law, with any regulation made under one, or with any professional guidance on interpretation, and the package holds no threshold at which a site becomes adequate. It does not assess translation quality, so fluent authored Spanish and clumsy authored Spanish are classified alike. It does not measure whether a person can obtain help, which turns on the telephone line, the intake desk, the interpreter roster and the hours at which someone answers, none of which a website states and none of which a crawler reads.

Questions about who receives service in which language are answered with survey, administrative and interview data. A website classification is one observable in that work: what an organization published, at the address where it published it, on the day it was read.

The baseline is English. Every classification rule asks what a site offers in addition to English, the rules were written and their agreement measured on United States websites, and the language word lists cover the languages spoken by immigrant populations in the United States. Carrying the package into a setting with a different default language, such as Welsh and English or Finnish and Swedish, means rebuilding the English entry in the languages list, the direction of the machine_translate class and the word lists, and the agreement figure below would not carry across. The limits of a classification produced within this scope are in LIMITATIONS.md, and the measurement behind each limit accompanies the paper.

5. Accuracy

Classification agreement with a gold dataset coded blind by language models is 93.2% (Cohen's kappa 0.8962) on 1,861 United States websites, 94.3% (0.9043) over the 926 local government sites and 92.1% (0.8788) over the 935 nonprofit organization sites. Carrying the design weights the sites were drawn under, the pooled figure is 94.7% [92.8, 96.1] with a kappa of 0.9124.

The figure covers three classes, english_only, machine_translate and true_multilingual. unreachable and machine_translate_error are counted beside it and never inside it, which is why the 1,861 is smaller than the 2,000 sites drawn. LIMITATIONS.md carries that ladder, the per-class recall and precision, and the build the table belongs to.

6. Limitations

LIMITATIONS.md states what a reader must know to interpret a result, including what the figures above do and do not cover. The measurement behind each limit accompanies the paper and is not part of this distribution.

7. Documentation

These files travel with the source distribution and are not in the wheel, so a reader who installed the wheel alone can retrieve them with pip download --no-binary :all: langaccess.

8. Citation

Cite the software. CITATION.cff holds the record, and the version belongs in the citation, because a classification is the output of one rule set. The accompanying paper this document refers to is not published yet.

Methods wording

A verdict in a table is the output of a rule set, a crawl budget and a date, and a reader who has only the tool's name cannot tell which of those produced the number. Adapt this, filling in the four values from your own run:

Website language access was classified with langaccess 0.1.0 (Yoo, 2026), which renders each site in a headless browser, reads up to N pages per site, and assigns one of five classes from the text it finds and the translation machinery it detects. Sites were audited between DATE and DATE. Of the N sites, N could not be read and are reported as unreachable and not as English-only. The package reports 93.2% agreement with a gold dataset coded by language models on 1,861 sites.

Four clauses get left out most often, and each changes what the numbers mean.

Report unreachable as its own row. A site behind a bot wall, a domain that has lapsed and a server that timed out all land here, and folding them into any substantive class puts a measurement error inside a finding. The unreachable class in LIMITATIONS.md has the reasoning.

Name the crawl budget. The class depends on how much of the site was read. A Spanish page four clicks deep is found by a run that reads twenty pages and missed by a run that reads five, and both runs are correct about what they read. Result.read_quality carries the number for each site, and capture_acceptance says whether the search was thin enough that an absence claim should not be made from it.

Give the dates. Sites are rebuilt, translation plugins are added and removed, and a domain lapses. A verdict is a statement about a site on a day.

Name the build that produced the classes. Two builds can classify one stored capture differently, in either direction, so two tables produced from the same addresses by different builds are not comparable. A released version names one rule set, and 0.1.0 is the version every figure in LIMITATIONS.md was measured on. A working copy taken from the repository between releases names none, so a study running from source records the revision it ran.

If a reviewer asks which rules produced a class, every result carries them: Result.rules is the list of numbered codebook rules that fired, and each piece of evidence carries the URL and the quoted text that decided it. Those fields are what a reviewer should receive, in place of a summary of them.

9. Related work

Web accessibility auditors. Auditors such as axe-core check the language DECLARATION: WCAG success criterion 3.1.1 asks that the language of a page be programmatically determinable, and the axe-core rules for it test whether a lang attribute is present and syntactically valid, so a widget that rewrites lang="en" to lang="es" passes while adding no authored Spanish. One rule in the wider family does compare the declared tag against the language of the text, ACT rule ucwvc8, and it is absent from axe-core; the implementation that carries it furthest asks a caller-supplied oracle what the language is, ships no oracle, and marks itself experimental. The models also do not fit. Every axe finding is a property of a node in the rendered tree, and the difference between the served document and the rendered one is a property of no node. The context axe accepts is an element, a NodeList, a selector, an include and exclude pair, frames or shadow roots, never an address, so the second fetch, the crawl and the sitemap read have nowhere to go in it.

Federal positions on machine translation. The line between authored and widget text is a live disagreement. The Department of Justice said in 2011 that "The use of machine or automatic translations is strongly discouraged even if a disclaimer is added" (archived, because lep.gov is suspended), and the Attorney General's memorandum of 14 July 2025 encourages "responsible use of artificial intelligence and machine translation" instead. Executive Order 14224 revoked EO 13166 (90 FR 11363) and the 2002 LEP Guidance was rescinded (90 FR 15721), while Title VI stands. This package holds no view on whether machine translation satisfies any obligation.

Language identification. Which language a passage is written in is decided by fastText's lid.176, on the languages the package's own word lists do not cover, behind a confidence floor and the orthographic gates. The model ships inside the package under CC BY-SA 3.0; see section 10.

The prior audits of organizational websites, the work on detecting machine-translated text, and the full reference list are in the paper.

10. Licence

MIT. See LICENSE. Every line of code in this package is under those terms.

One shipped file is not code and is not MIT. src/langaccess/data/lid.176.ftz is fastText's lid.176 language identification model, distributed by its authors under CC-BY-SA 3.0 and redistributed here byte for byte, so it stays under the licence it came with. LICENSES/lid.176.CC-BY-SA-3.0.txt holds the attribution line, where the model came from, the sha256 of the shipped bytes and the full text of the licence. A built wheel carries it next to the MIT LICENSE at langaccess-<version>.dist-info/licenses/LICENSES/lid.176.CC-BY-SA-3.0.txt, and both files are named as License-File in the metadata, so an installed copy answers the licence question without the repository.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

langaccess-0.1.0.tar.gz (1.9 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

langaccess-0.1.0-py3-none-any.whl (1.1 MB view details)

Uploaded Python 3

File details

Details for the file langaccess-0.1.0.tar.gz.

File metadata

  • Download URL: langaccess-0.1.0.tar.gz
  • Upload date:
  • Size: 1.9 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.9

File hashes

Hashes for langaccess-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b1f3d4a2f428b32f93d98a10499a7a41ff602f53c5184b0b5fdfc1079db9d86d
MD5 c5e8a688009ff9331413787bb0e9d545
BLAKE2b-256 e42363408ec4554311b9ac3189f96111cc217905b6b3fe51e5a30ddd2553b4f7

See more details on using hashes here.

File details

Details for the file langaccess-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: langaccess-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 1.1 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.9

File hashes

Hashes for langaccess-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2a297f864d3c31435743489b9dd4b3d44717f02211f58045fbcf8e51f83f5abd
MD5 0008d0f2a5bfa23400818c4baa310e22
BLAKE2b-256 94ba34538947aae1e82165911b4f32f3d504fb12061692617ab0deb032152603

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page