The Gazette’s Published Record, Downloadable

Abstract

The explorations and tools on this site are not built from a static snapshot: they read from GitHub Releases this project publishes on top of what the DOF and the SCJN already make public. This page is about the first of those releases, notas-archivo, tagged from dofjson’s –archivo mode against the DOF’s own open-data service: what it contains, what a reader will actually find inside it, and the monthly workflow that keeps it growing.

Open in Colab

1 Built on what the DOF and the SCJN already publish

Everything on this site is built on top of institutions that already publish this material themselves. The Diario Oficial de la Federación (DOF), Mexico’s official gazette, publishes through two systems: SIDOF, its JSON open-data service (sidof.segob.gob.mx), and www.dof.gob.mx, its own website — the second recovers whole publication days SIDOF sometimes drops silently, reported as an empty, valid day, the same as a Sunday (see dofjson). The Suprema Corte de Justicia de la Nación (SCJN) separately publishes consolidated legal text — a law’s text as it read right after each reform — through its own search service, which nota2md crawls.

The DOF/SIDOF remains the official source of legal text. The SCJN is not: its consolidated texts are convenient, and mark their own editorial insertions, but the gazette is still what the law actually says. Every file this project writes from the SCJN corpus keeps a fuente: scjn header for exactly that reason — see Federal Laws.

2 What the project adds

On top of what the DOF and the SCJN publish, LegalIA maintains two GitHub Releases: notas-archivo, the DOF’s daily notes index — what the rest of this page documents — and scjn-leyes, the SCJN’s consolidated law texts, documented on Federal Laws. Both are downloadable in one step:

nota2md download all

which fetches notas-archivo into dofjson’s cache and scjn-leyes into nota2md’s own — each package keeps its own directory, so clearing one never clears the other. nota2md download gazette-metadata and nota2md download federal-laws fetch them one at a time; the rest of this page reads the first.

3 What can actually be read

Every note carries three flags — existeHtml, existeImagen, existePdf — saying which forms of it the gazette actually holds. They are the first thing to check before any downstream work, because they decide how a note can be read at all: a note with HTML is converted directly by nota2md’s default path, cleanly and cheaply; a note that exists only as scanned pages or as a PDF has to go through OCR, which is what nota2md borrows dof2md for. And the answer changes drastically across the century.

The flags are not in the titles dataset described below — that keeps only codNota, titulo, fecha and codOrgaUno — so this reads the release assets themselves. One command puts them all in the per-user cache (~59 MB, 116 assets; a second run downloads nothing):

nota2md download gazette-metadata

and the cell below aggregates every note in them. It is the slowest cell on this site — it parses all 1.2 million records, a few minutes — which is why the site renders this page from its frozen output rather than re-executing it.

Reading the three flags out of every cached asset, aggregated by decade
import collections
import datetime as dt
from pathlib import Path

import dofjson
import pandas as pd
from dofjson.titulos import CACHE_DIR

# `existeImagen` is not a clean "S"/"N" domain: 48 notes (all of them in the
# 2010s) carry "Y" instead. It reads as a data-entry variant of "S" -- those
# notes all have HTML and a PDF as well, and nothing distinguishes them
# otherwise -- so it is counted as present. What matters more than the
# decision, on 48 records out of 1.2 million, is not assuming the domain is
# binary and silently dropping them: compare against the truthy values
# explicitly.
PRESENTE = ("S", "Y")
FLAGS = {"HTML": "existeHtml", "Scanned images": "existeImagen", "PDF": "existePdf"}

por_decada = collections.defaultdict(collections.Counter)
sin_nada = 0
pdf_sin_imagen = 0
mas_antigua_pdf_sin_imagen = None
for asset in sorted(Path(CACHE_DIR).glob("*.tgz")):
    for nota in dofjson.notas_de_tgz(asset.read_bytes()):
        # The note's own `fecha`, not the day file it is packed in: 160 notes
        # disagree with their file, and for a handful the year does too.
        dia, mes, anio = (int(x) for x in nota["fecha"].split("-"))
        decada = anio // 10 * 10
        fila = por_decada[decada]
        fila["notes"] += 1
        presentes = 0
        for etiqueta, campo in FLAGS.items():
            if nota.get(campo) in PRESENTE:
                fila[etiqueta] += 1
                presentes += 1
        if presentes == 0:
            fila["none"] += 1
            sin_nada += 1
        # Is the PDF series redundant with the images? Counted rather than
        # assumed -- it is not, and the figure keeps all three because of it.
        if nota.get("existePdf") in PRESENTE and nota.get("existeImagen") not in PRESENTE:
            fila["pdf_only"] += 1
            pdf_sin_imagen += 1
            clave = (dt.date(anio, mes, dia), nota["codNota"])
            if mas_antigua_pdf_sin_imagen is None or clave < mas_antigua_pdf_sin_imagen:
                mas_antigua_pdf_sin_imagen = clave

cobertura = pd.DataFrame(
    [
        {"decade": d, "notes": c["notes"], "none": c["none"],
         "pdf_only": c["pdf_only"],
         **{etiqueta: 100 * c[etiqueta] / c["notes"] for etiqueta in FLAGS}}
        for d, c in sorted(por_decada.items())
    ]
).set_index("decade")
cobertura[["notes", *FLAGS]].round(1)
notes HTML Scanned images PDF
decade
1910 8913 0.0 51.7 0.0
1920 46968 0.0 99.1 100.0
1930 41619 0.0 92.3 100.0
1940 52391 0.0 99.1 100.0
1950 51244 0.0 99.6 100.0
1960 49634 0.0 99.1 100.0
1970 66467 61.1 97.2 100.0
1980 63565 88.7 98.2 100.0
1990 107736 98.9 85.7 99.9
2000 265131 100.0 99.5 99.9
2010 323343 99.9 99.5 99.6
2020 160092 100.0 99.9 99.9
Code
import plotly.graph_objects as go

# The site's validated categorical palette, in fixed assignment order -- the
# same three hues, meaning the same three things, as on the other pages.
BLUE, GREEN, MAGENTA = "#2a78d6", "#008300", "#e87ba4"
INK, MUTED, GRID = "#52514e", "#898781", "#e1e0d9"
PLOTLY_FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"

# Colour is never the only thing carrying identity: each series also has its
# own marker shape and dash pattern, so the figure survives greyscale, print
# and colour-vision deficiency -- and the table above it is the same data.
SERIES = [
    ("HTML", BLUE, "circle", "solid"),
    ("Scanned images", GREEN, "square", "dash"),
    ("PDF", MAGENTA, "diamond", "dot"),
]

etiquetas = [
    f"{d}s<br><span style='font-size:11px'>{n:,}</span>"
    for d, n in zip(cobertura.index, cobertura["notes"])
]

fig = go.Figure()
for nombre, color, simbolo, guion in SERIES:
    fig.add_trace(
        go.Scatter(
            x=etiquetas, y=cobertura[nombre], name=nombre, mode="lines+markers",
            line=dict(color=color, width=2, dash=guion),
            marker=dict(color=color, size=9, symbol=simbolo,
                        line=dict(color="white", width=2)),
            hovertemplate=f"{nombre}: %{{y:.1f}}%<extra></extra>",
        )
    )

fig.update_layout(
    font=dict(family=PLOTLY_FONT, size=13, color=INK),
    paper_bgcolor="white", plot_bgcolor="white", height=470,
    # A roomier top margin than the site's other figures: this is the one
    # with a horizontal legend sitting above the plot area, and 16px clips it.
    margin=dict(l=64, r=24, t=52, b=56),
    hovermode="x unified",
    hoverlabel=dict(bgcolor="white", bordercolor=GRID, font=dict(color=INK, size=12)),
    legend=dict(font=dict(size=12, color=INK), orientation="h",
                yanchor="bottom", y=1.0, xanchor="left", x=0),
)
fig.update_xaxes(showgrid=False, showline=False, zeroline=False, ticks="",
                 tickfont=dict(color=MUTED), title_text="")
fig.update_yaxes(range=[0, 103], dtick=25, ticksuffix="%", showgrid=True,
                 gridcolor=GRID, gridwidth=1, showline=False, zeroline=False,
                 ticks="", tickfont=dict(color=MUTED),
                 title_text="Notes with this form available",
                 title_font=dict(color=INK, size=13))
# The one editorial point the eye should not have to hunt for.
fig.add_annotation(
    x=etiquetas[6], y=61.1, text="HTML begins", showarrow=True, arrowhead=0,
    arrowcolor=MUTED, ax=-4, ay=48, font=dict(color=INK, size=12), xanchor="left",
)
fig
Figure 1: Share of each decade’s notes for which the gazette holds HTML, scanned images, or a PDF. The note count under each decade is the weight behind its percentages — the 1910s’ 51.7% is half of 8,913 notes, the 2010s’ of 323,343. Series are distinguished by marker shape and dash as well as colour; hover for exact values.
The numbers cited below, computed rather than written
from IPython.display import Markdown

# A decade is "in the HTML era" at 1% or more, not at any non-zero value: a
# few dozen stray records carry `existeHtml: "S"` in decades that are
# otherwise entirely pre-digital, and taking the first non-zero decade would
# put the boundary half a century too early.
UMBRAL_ERA_HTML = 1.0
en_la_era = cobertura[cobertura["HTML"] >= UMBRAL_ERA_HTML]
primer_html = int(en_la_era.index.min())
antes = cobertura.loc[: primer_html - 10]
sueltas = int((antes["HTML"] / 100 * antes["notes"]).round().sum())
decada_vacia = int(cobertura["none"].idxmax())
Markdown(
    f"Three things the figure says. **The HTML era begins in the "
    f"{primer_html}s** — {cobertura.loc[primer_html, 'HTML']:.0f}% of that "
    f"decade's notes, against {sueltas} stray records in the whole half-century "
    f"before it — and is only effectively universal from the 2000s on. Before "
    f"that boundary, every note is an OCR job. **The gap is in the imaging, not "
    f"the HTML**: scanned images are near-complete for most of the century, but "
    f"the {decada_vacia}s sit at "
    f"{cobertura.loc[decada_vacia, 'Scanned images']:.1f}% and the 1990s dip to "
    f"{cobertura.loc[1990, 'Scanned images']:.1f}%, which is why the PDF series "
    f"is worth plotting separately rather than assumed to track it. And "
    f"**{sin_nada:,} notes have no content at all** — no HTML, no image, no PDF "
    f"— every one of them in the {decada_vacia}s, where they are exactly the "
    f"half of the decade that was never imaged. They exist in the index, with a "
    f"title and a date, and no path to their text."
)

Three things the figure says. The HTML era begins in the 1970s — 61% of that decade’s notes, against 2 stray records in the whole half-century before it — and is only effectively universal from the 2000s on. Before that boundary, every note is an OCR job. The gap is in the imaging, not the HTML: scanned images are near-complete for most of the century, but the 1910s sit at 51.7% and the 1990s dip to 85.7%, which is why the PDF series is worth plotting separately rather than assumed to track it. And 4,308 notes have no content at all — no HTML, no image, no PDF — every one of them in the 1910s, where they are exactly the half of the decade that was never imaged. They exist in the index, with a title and a date, and no path to their text.

Why the PDF series is not redundant with the images
solo_pdf = cobertura["pdf_only"].sort_values(ascending=False)
fecha_mas_antigua, cod_mas_antigua = mas_antigua_pdf_sin_imagen
Markdown(
    f"The PDF series is not redundant with the images. **{pdf_sin_imagen:,} "
    f"notes have a PDF and no scanned image**, concentrated in the "
    f"{int(solo_pdf.index[0])}s ({int(solo_pdf.iloc[0]):,}) and the "
    f"{int(solo_pdf.index[1])}s ({int(solo_pdf.iloc[1]):,}), the oldest of them "
    f"`codNota` {cod_mas_antigua}, published "
    f"{fecha_mas_antigua:%-d %B %Y}. A pipeline that treated \"has an image\" "
    f"as the OCR-eligibility test would drop every one of them."
)

The PDF series is not redundant with the images. 25,500 notes have a PDF and no scanned image, concentrated in the 1990s (15,276) and the 1930s (3,214), the oldest of them codNota 4438455, published 26 November 1921. A pipeline that treated “has an image” as the OCR-eligibility test would drop every one of them.

Read downstream, the three series are a routing table. A note the HTML series covers takes legal_provisions’ cheap path — nota2md converts its HTML directly, no OCR and no page-boundary guessing. A note the HTML series misses but one of the other two covers is an OCR job: nota2md downloads the scanned pages, or the note’s own PDF sliced out of the edition, and runs them through dof2md/mineru before cutting the note out of the page. A note none of the three covers cannot be built from this archive at all, whichever tool is pointed at it.

4 How the archive grows

The release is kept current by a scheduled workflow, notas-archivo.yml, that runs once a month, at 09:00 UTC on the 1st and updates the release notas-archivo. In the ordinary case it downloads the month that just ended with dofjson --archivo --desde ... --hasta ... and uploads it as notas-YYYY-MM.tgz, overwriting any asset of the same name (--clobber), so a re-run of the same month is harmless.

January 1st is a special case: the month that just ended is December, so instead of one more monthly asset the workflow downloads the entire year that just closed, publishes it as a single notas-YYYY.tgz, and deletes the twelve monthly assets that covered it — folding a year’s worth of monthly archives into the one closed-year asset the pattern above expects. The workflow can also be run by hand, for a specific month, through its workflow_dispatch input — useful for backfilling or forcing a re-run. None of this depends on the workflow always succeeding: dofjson --archivo resumes from where a previous attempt left off, so a failed or partial run is picked up cleanly the next time it fires.

5 One release, one file per publication day

The repository keeps its collected Diario Oficial data out of git and in a single GitHub Release instead, notas-archivo: the raw archive is large, grows every month, and would not sit comfortably as committed files. Each asset in that release is a .tgz of one JSON file per publication day, produced by dofjson’s incremental --archivo mode against the DOF’s open-data service — the day’s index of what was published, never a note’s full content or its scanned images.

One asset, one span of time

A closed year packs into a single notas-YYYY.tgz; the year under way ships instead as one notas-YYYY-MM.tgz per finished month, until it closes.

One file, one day

Inside each archive, one JSON file per publication day — DDMMYYYY-notas.json — holding that day’s morning, evening and extraordinary editions.

One record, one note

Each note carries its codNota identifier, titulo, the section and page it was printed on, its issuing branch, and whether HTML, PDF or scanned images exist for it.

A single note record looks like this (trimmed to the fields the site’s pages actually use):

{
  "codNota": 5793639,
  "titulo": "Acuerdo por el que se establecen acciones para la primera etapa...",
  "fecha": "15-07-2026",
  "codOrgaUno": "PE",
  "nombreCodOrgaUno": "PODER EJECUTIVO",
  "codSeccion": "UNICA",
  "pagina": 5,
  "existeHtml": "S",
  "existeImagen": "S",
  "existePdf": "S"
}

Stub entries without a title — an internal bookkeeping artifact of the service — are filtered out before the file is written, using the same quita_notas_sin_titulo helper dofjson uses elsewhere.

6 The release today

Rather than describe the current contents in prose — which would be stale the day after this page is rendered — the cell below asks GitHub directly:

Code
import re
import pandas as pd
import requests

resp = requests.get(
    "https://api.github.com/repos/INGEOTEC/LegalIA/releases/tags/notas-archivo",
    timeout=30,
)
release = resp.json()
assets = sorted(release["assets"], key=lambda a: a["name"])

rows = []
for a in assets:
    m = re.match(r"notas-(\d{4})(?:-(\d{2}))?\.tgz", a["name"])
    year, month = m.group(1), m.group(2)
    rows.append(
        {
            "asset": a["name"],
            "span": year if month is None else f"{year}-{month}",
            "kind": "year" if month is None else "month",
            "size_MB": round(a["size"] / 1_000_000, 2),
        }
    )
table = pd.DataFrame(rows)
print(
    f"{len(table)} assets, spanning {table.span.min()}\u2013{table.span.max()}: "
    f"{(table.kind == 'year').sum()} closed years, "
    f"{(table.kind == 'month').sum()} months of the year under way "
    f"({table.size_MB.sum():.0f} MB total)."
)
table.tail(8)
117 assets, spanning 1917–2026-08: 109 closed years, 8 months of the year under way (59 MB total).
asset span kind size_MB
109 notas-2026-01.tgz 2026-01 month 0.05
110 notas-2026-02.tgz 2026-02 month 0.07
111 notas-2026-03.tgz 2026-03 month 0.09
112 notas-2026-04.tgz 2026-04 month 0.09
113 notas-2026-05.tgz 2026-05 month 0.09
114 notas-2026-06.tgz 2026-06 month 0.11
115 notas-2026-07.tgz 2026-07 month 0.10
116 notas-2026-08.tgz 2026-08 month 0.08

The tail of the table is the interesting part: it is exactly the boundary described above, the last couple of closed years followed by the monthly assets accumulated so far for the year in progress. Run this page again next month and one more row will have appeared there.

7 Getting the raw archive yourself

Any asset can be pulled without cloning the repository:

gh release download notas-archivo -p notas-2025.tgz
tar xzf notas-2025.tgz

Without gh, every entry of the assets list in the API response used above carries a browser_download_url — a direct link to that .tgz. Pick the one you want out of that same response:

url = next(
    a["browser_download_url"] for a in assets if a["name"] == "notas-2025.tgz"
)
# https://github.com/INGEOTEC/LegalIA/releases/download/notas-archivo/notas-2025.tgz

and follow it with any HTTP client:

curl -LO https://github.com/INGEOTEC/LegalIA/releases/download/notas-archivo/notas-2025.tgz
tar xzf notas-2025.tgz

To extend a local copy going forward instead of downloading a fixed snapshot, dofjson --archivo is the same tool the monthly workflow runs, and it is resumable in the same way: interrupt it and rerun it, and it picks up only the days it is still missing.

8 Streaming every title in the archive

Beyond browsing one release asset at a time, dofjson yields a compact codNota + titulo + fecha + codOrgaUno record for every note ever published: legal_provisions_titles reads each notas-archivo asset in the local cache in turn and keeps only those fields from every titled note (titulo is Spanish for “title”, fecha for “date”) — codNota to fetch the note’s full content later (dofjson.get_nota), titulo for exploratory analysis of the titles themselves with other techniques (in the spirit of DOF Titles), fecha to place each title in time, and codOrgaUno to group by issuing branch.

Nothing is written to disk: the titles are a projection of the cache the rest of the packages already share, so the archive is never copied a second time. Populate that cache once and every pass afterwards is local:

nota2md download gazette-metadata

Then the titles are a plain iterable, with no dataset file in between:

from dofjson import legal_provisions_titles

titulos = legal_provisions_titles(log=lambda x: None)

In the following line, the first title is retrieved. These titles are not sorted globally by the codNota field; the ordering applies only at the day level.

next(titulos)
{'codNota': 4430696,
 'titulo': 'ES ACEPTADO por esta Secretaría el nombramiento del C. Lic. Agustín Ruiz Olloqui, como adscrito a la Notaría nº 9 de esta ciudad',
 'fecha': '02-01-1917',
 'codOrgaUno': 'PE'}

Methodological note

This page is a Jupyter notebook that queries the GitHub REST API for the notas-archivo release at render time; the table above reflects the release’s contents at the moment this page was last rendered, not necessarily today. The archive, the workflow that maintains it and the aggregation script that feeds the DOF Titles page are all part of the repository.

The archive is itself a product of the collaboration that runs through the whole project: the incremental --archivo downloader and the titles reader in dofjson, the monthly workflow that packages and publishes each release, and this page were written by the LegalIA team together with Claude, Anthropic’s coding assistant, through Claude Code, with the authors reviewing and validating each contribution before it landed.