Live: https://www.openhistorymap.org/creators/
A directory of history / archaeology creators where every reel and video is indexed by the time period it talks about and the places it talks about — so you can ask "what has anyone made about the Aegean between 1300 and 1100 BCE?" rather than "what did this channel upload last month".
211 items from 30 creators in five languages, across 154 places in 41 countries, spanning 43 000 BCE to 2023.
The whole thing is three CSV files and a static page. There is no server-side code, no framework, and no build step.
Want your channel listed? See CONTRIBUTING.md — open an issue and a bot opens the pull request for you.
data/influencers.csv who the creators are
data/places.csv the gazetteer (id, name, lat, lon, wikidata)
data/videos.csv the index: one row per reel / video
data/pending.csv machine-written: new uploads with no period or place yet
index.html app.js style.css the browser UI (Leaflet + a warped timeline)
tools/validate.py referential-integrity check over the CSVs
tools/build_sqlite.py optional: derive directory.db for SQL querying
tools/refresh_feeds.py poll the channels for uploads not yet indexed
tools/issue_to_row.py turn a submission issue into validated CSV rows
tools/channel_videos.py read a channel's videos/shorts tab, past the 15-item feed
tools/build_pending.py write data/pending.csv: new uploads, not yet indexed
tools/geocode.py turn a place name into a real coordinate (Nominatim)
tools/build_site.sh assemble _site/ for GitHub Pages
./serve.sh # http://localhost:8088/The page fetch()es the CSVs, so opening index.html over file:// will not
work — it needs to be served over HTTP. Any static server does; serve.sh is
just python3 -m http.server.
influencers.csv — one row per creator.
| column | meaning |
|---|---|
id |
slug, referenced by videos.influencer_id |
youtube_channel_id |
canonical UC... id — the key tools/refresh_feeds.py polls |
name, handle |
display name and primary @handle |
youtube, instagram, tiktok, facebook, website |
profile URLs, any may be blank |
language |
ISO code of the main language |
focus |
free text — what they cover |
avatar_url |
optional image URL |
verified |
yes once the links have been machine-checked |
notes |
free text |
places.csv — the gazetteer. Each place is entered once and referenced
by id, so coordinates stay in one place when twenty videos mention Pompeii.
wikidata is the Q-number, for later reconciliation against OHM.
videos.csv — the actual index. One row per item.
| column | meaning |
|---|---|
id |
slug/serial |
influencer_id |
→ influencers.id |
title |
as published |
platform |
youtube, reel, short, tiktok, instagram, facebook, podcast, article |
url |
direct link to the item |
thumbnail |
optional image URL |
published |
upload date, YYYY-MM-DD, optional |
year_start, year_end |
the period the content is about, not the upload date. Integers; negative = BCE. Equal values mean a single year. |
era |
human label for that span, e.g. Late Bronze Age |
places |
;-separated places.id values |
tags |
;-separated free tags |
verified |
yes once the URL, title and creator are confirmed (see Provenance) |
notes |
free text |
year_start / year_end are the join key to the rest of OHM: they are plain
years on the same axis the tileservers use, so this directory can later be
overlaid on OHM map layers without a conversion step.
- If the creator is new, add a row to
data/influencers.csv. - If a place is new, add a row to
data/places.csv(get lat/lon and the Q-number from Wikidata). - Add a row to
data/videos.csvreferencing both. python3 tools/validate.py
The validator checks for duplicate ids, dangling influencer_id / place
references, reversed or non-numeric year spans, out-of-range coordinates,
relative URLs, and unknown platform values. It exits non-zero on errors;
warnings (a video with no place, an unused gazetteer entry) do not fail.
python3 tools/build_sqlite.py # → directory.db
sqlite3 -header -column directory.db \
"SELECT title, creator, year_start, year_end, places FROM v_directory
WHERE year_start < -1000;"directory.db is a derived, disposable artifact — the CSVs remain the source
of truth. The schema normalises places and tags into video_places and
video_tags join tables and exposes a flattened v_directory view.
The year axis is cube-root warped: a linear axis from the Palaeolithic to now would compress everything after 3000 BCE into the last few percent of the bar. The warp gives recorded history a third of the bar while still reaching −50 000, which is where the oldest indexed item (the Aurignacian) sits. The histogram behind the slider counts items whose span overlaps each bucket, and responds to every filter except the year range itself.
The 211 items are real videos, verified end to end, from 29 creators in five languages (English, French, German, Italian, Spanish):
- Each creator's YouTube handle was resolved to a canonical
youtube_channel_idby reading therel="canonical"link on the channel page. Three of the handles first guessed from memory were wrong and were corrected this way (@miniminutemanis a different person; Metatron's history channel is@Metatronrealhistory;@Premodernistdoes not exist, it is@premodernist_history). - Titles, video ids and publication dates come from each channel's own Atom feed
(
youtube.com/feeds/videos.xml?channel_id=...), so nothing is transcribed by hand and no item can be attributed to the wrong creator. - Every row was then re-checked against YouTube's oEmbed endpoint: 210 of 211
URLs resolve, each title matches the feed exactly, and each
author_namematches the creator the row is attributed to. The one exception (v138, Time Team Classics) has embedding disabled, which makes oEmbed return 401; it was confirmed live on its watch page instead and itsnotessay so. platformis set from whether the id resolves under/shorts/— 69 of the items are Shorts or reels, 142 long-form.
What verified=yes does and does not mean. It means the link, title,
creator and publication date are confirmed. It does not mean someone watched
the video to confirm the period and place: year_start, year_end, era and
places are editorial readings of the subject, and where the reading is loose or
a place stands in for a region, the notes column says so. Twenty items are
deliberately left with no place — a survey video, or a subject with no single
site — and those simply do not appear on the map.
Archaeosoup is listed as a creator but has no items: the channel resolves, but its feed returns no recent uploads.
Language coverage. 17 anglophone creators (140 items), 5 French (30),
3 German (17), 2 Italian (13), 2 Spanish (11). influencers.csv carries the
language code and the UI filters on it. The non-English creators are indexed
with their original titles, untranslated — the title is what you would search
for on the platform, so translating it would make the row harder to find, not
easier. The era and tags columns stay in English so they remain a shared
index across languages.
Note that reach is uneven by design of the sources, not of the field: French
archaeology is well served by Inrap, the national preventive-archaeology
institute, whose films are site-by-site and so map unusually cleanly. A few
Inrap entries are Version LSF — French sign-language versions of a site film —
which is recorded in notes.
Platforms other than YouTube. A creator row can carry instagram, tiktok
and facebook links, but only YouTube exposes a public per-channel feed. Meta
and TikTok put individual reel permalinks behind a login wall, so for a
reel-first creator the profile is recorded on the creator row while the indexed
items are whatever is publicly enumerable — usually their YouTube Shorts.
Facebook and TikTok reels can still be added by hand, one row at a time, with a
permalink copied from the app.
python3 tools/refresh_feeds.py # list uploads not yet indexed
python3 tools/refresh_feeds.py --csv # same, as skeleton rows to paste inIt only reports; it never edits the CSVs, because deciding the period and place
of a new video is a judgement call. It also re-checks that each feed is still
titled what influencers.csv expects, which is what caught the wrong channel ids
in the first place.
data/pending.csv is the one machine-written file here. Every Monday the sweep
rewrites it with recent uploads that are not in videos.csv, commits it, and
redeploys the site. Each creator's profile panel then shows them under Latest
uploads.
Those rows carry no year_start, no year_end, no era and no places, and
they never reach the map or the timeline. Deciding what period and place a video
covers is the editorial judgement this directory exists to record, so the robot
does not guess at it — it only says "this exists and nobody has looked at it yet".
Promoting one is the normal contributionroute: open an Add a video or reel issue, or send a pull request.
Four workflows, in .github/workflows/:
| Workflow | Trigger | What it does |
|---|---|---|
validate.yml |
every push and PR | runs tools/validate.py and the SQLite build |
pages.yml |
push to main |
validates, assembles _site/, deploys to GitHub Pages |
submission.yml |
issue labelled add-channel / add-item |
runs tools/issue_to_row.py, opens a PR, or comments the error back on the issue |
refresh.yml |
Mondays 06:17 UTC | rewrites data/pending.csv, commits and republishes it, and files the deep backlog as one issue |
The submission bot prepares, a human merges. It resolves the channel id, reads the real title and upload date, works out whether the item is a Short, and refuses anything it cannot verify — but the period and place it writes are the submitter's claim, and that is what a reviewer is there to check.
The issue body is untrusted input: it is passed to the script through an environment variable and a file, never interpolated into a shell command. The parser is stdlib-only, so there is no third-party action in the path that writes to the data.