Skip to content

Author voices

gutenberg_kg.author_voices

Each author's voice for a moot: gender and a coarse accent, from Wikidata.

A moot's tree speaks as its book's author, so the forest picks a voice of the author's gender and, where the browser has one, the author's accent. Neither is in the corpus, so this module looks them up on Wikidata and keeps them in corpus/authors/voices.json, beside the author pages and apart from ingestion:

  • gender: "male", "female" or None, from sex or gender (P21).
  • accent: "en-US", "en-GB" or None, from country of citizenship (P27). An American citizenship gives en-US; a British, Irish or Commonwealth one en-GB; anything else, including every translated author, None, which leaves the accent to the system.

Each entry also records the Wikidata item and the citizenship labels, so the table can be reviewed, and its source. An entry whose source is "manual" is never overwritten; the others are filled in once and kept unless refreshed.

accent_of(ids)

"en-US", "en-GB" or None for an author's P27 values; American wins.

Source code in src/gutenberg_kg/author_voices.py
74
75
76
77
78
79
80
def accent_of(ids: list[str]) -> str | None:
    """``"en-US"``, ``"en-GB"`` or ``None`` for an author's P27 values; American wins."""
    if _US & set(ids):
        return "en-US"
    if _GB & set(ids):
        return "en-GB"
    return None

author_pages(authors_dir=AUTHORS_DIR)

Every author page: slug -> {"name", "title"}.

title is the Wikipedia article title from the page's Wikipedia link, or None when the page has none.

Parameters:

Name Type Description Default
authors_dir Path

corpus/authors.

AUTHORS_DIR
Source code in src/gutenberg_kg/author_voices.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def author_pages(authors_dir: Path = AUTHORS_DIR) -> dict[str, dict]:
    """Every author page: slug -> ``{"name", "title"}``.

    ``title`` is the Wikipedia article title from the page's Wikipedia link,
    or ``None`` when the page has none.

    :param authors_dir: ``corpus/authors``.
    """
    pages = {}
    for page in sorted(authors_dir.glob("*/author.md")):
        text = page.read_text(encoding="utf-8")
        heading = re.search(r"^# (.+)$", text, re.MULTILINE)
        link = re.search(r"\*\*Wikipedia\*\*: https?://en\.wikipedia\.org/wiki/(\S+)", text)
        pages[page.parent.name] = {
            "name": heading.group(1).strip() if heading else page.parent.name,
            "title": urllib.parse.unquote(link.group(1)).replace("_", " ") if link else None,
        }
    return pages

fetch_json(url)

GET url from a Wikimedia API with a descriptive User-Agent.

Parameters:

Name Type Description Default
url str

The API URL.

required

Returns:

Type Description
dict

The decoded JSON.

Source code in src/gutenberg_kg/author_voices.py
58
59
60
61
62
63
64
65
66
def fetch_json(url: str) -> dict:
    """GET ``url`` from a Wikimedia API with a descriptive User-Agent.

    :param url: The API URL.
    :returns: The decoded JSON.
    """
    req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
    with urllib.request.urlopen(req, timeout=30) as resp:  # noqa: S310 -- fixed https hosts
        return json.load(resp)

gender_of(ids)

"male", "female" or None for an author's P21 values.

Source code in src/gutenberg_kg/author_voices.py
69
70
71
def gender_of(ids: list[str]) -> str | None:
    """``"male"``, ``"female"`` or ``None`` for an author's P21 values."""
    return next((_GENDER[i] for i in ids if i in _GENDER), None)

look_up(pages, fetch=fetch_json)

Look each author up on Wikidata.

Parameters:

Name Type Description Default
pages dict[str, dict]

slug -> {"name", "title"}, as :func:author_pages gives.

required
fetch Fetch

The JSON fetcher.

fetch_json

Returns:

Type Description
dict[str, dict]

slug -> voice entry. An author with no Wikipedia link is found by name and marked "wikidata-search" for review; one not found at all gets no gender or accent, and a placeholder such as Various is not looked up.

Source code in src/gutenberg_kg/author_voices.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def look_up(pages: dict[str, dict], fetch: Fetch = fetch_json) -> dict[str, dict]:
    """Look each author up on Wikidata.

    :param pages: slug -> ``{"name", "title"}``, as :func:`author_pages` gives.
    :param fetch: The JSON fetcher.
    :returns: slug -> voice entry. An author with no Wikipedia link is found by
        name and marked ``"wikidata-search"`` for review; one not found at all
        gets no gender or accent, and a placeholder such as ``Various`` is
        not looked up.
    """
    placeholder = {s for s, p in pages.items() if p["name"].casefold() in PLACEHOLDERS}
    titled = {s: p["title"] for s, p in pages.items() if p["title"]}
    by_title = _items_for_titles(sorted(set(titled.values())), fetch)
    items: dict[str, tuple[str, str]] = {}
    for slug, page in pages.items():
        if page["title"] and page["title"] in by_title:
            items[slug] = (by_title[page["title"]], "wikidata")
        elif not page["title"] and slug not in placeholder:
            found = _search_item(page["name"], fetch)
            if found:
                items[slug] = (found, "wikidata-search")
    entities = _entities(sorted({q for q, _ in items.values()}), "claims", fetch)
    countries = sorted(
        {c for q, _ in items.values() for c in _claim_ids(entities.get(q, {}), "P27")}
    )
    labels = {
        q: e.get("labels", {}).get("en", {}).get("value", q)
        for q, e in _entities(countries, "labels", fetch).items()
    }
    voices = {}
    for slug, page in pages.items():
        qid, source = items.get(slug, (None, "placeholder" if slug in placeholder else "not found"))
        entity = entities.get(qid, {}) if qid else {}
        citizenship = _claim_ids(entity, "P27")
        voices[slug] = {
            "name": page["name"],
            "gender": gender_of(_claim_ids(entity, "P21")),
            "accent": accent_of(citizenship),
            "citizenship": [labels.get(c, c) for c in citizenship],
            "wikidata": qid,
            "source": source,
        }
    return voices

merge_voices(old, new, refresh=False)

Combine a fresh lookup with the existing table.

A "manual" entry always stays. Other existing entries stay unless refresh; authors new to the corpus are added.

Returns:

Type Description
dict[str, dict]

The merged table, sorted by slug.

Source code in src/gutenberg_kg/author_voices.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def merge_voices(
    old: dict[str, dict], new: dict[str, dict], refresh: bool = False
) -> dict[str, dict]:
    """Combine a fresh lookup with the existing table.

    A ``"manual"`` entry always stays. Other existing entries stay unless
    ``refresh``; authors new to the corpus are added.

    :returns: The merged table, sorted by slug.
    """
    merged = dict(new)
    for slug, entry in old.items():
        if entry.get("source") == "manual" or not refresh:
            merged[slug] = entry
    return dict(sorted(merged.items()))

read_voices(path=VOICES_PATH)

The voice table, or {} when it has not been written.

Source code in src/gutenberg_kg/author_voices.py
237
238
239
def read_voices(path: Path = VOICES_PATH) -> dict[str, dict]:
    """The voice table, or ``{}`` when it has not been written."""
    return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}

voice_for(author, voices)

{"gender", "accent"} for a catalog author name, both None when unknown.

Parameters:

Name Type Description Default
author str

The author as the catalog names it.

required
voices dict[str, dict]

The voice table.

required
Source code in src/gutenberg_kg/author_voices.py
242
243
244
245
246
247
248
249
def voice_for(author: str, voices: dict[str, dict]) -> dict:
    """``{"gender", "accent"}`` for a catalog author name, both ``None`` when unknown.

    :param author: The author as the catalog names it.
    :param voices: The voice table.
    """
    entry = voices.get(_slugify(author), {})
    return {"gender": entry.get("gender"), "accent": entry.get("accent")}