By far the easiest way to work with .blend files is to shell out to Blender, let it open the file for you, and use Blender's Python API to get whatever you need.
blender -b your_file.blend -P python_script.py
Besides not having to parse the file yourself, there's lots of stuff (like "where is this object on frame X") that can't be read from .blend alone but need the actual algorithms Blender uses, so it's easiest to just reuse Blender itself.
The downside is the Python API breaks all the time.
When I was learning Blender as a teenager I discovered this and built a website that let you enter text and it generated a 3D model of the text. I was young and naïve and didn't realize how dumb of an architecture it was to simply shell out to a Blender process without any sort of queue, and it just spiked 100% cpu every time. But it worked!
It's interesting to me that Blender basically dumps everything to disk in the most straightforward way possible and lets the loader figure out how to interpret that, since it's similar to how Factorio loads save files from older versions of the game. Though in Factorio's case it's implemented with a lot of `if(saveVersion >= 0.17.123) xyz = stream.readInt32()` and compatibility gets dropped every few versions. The 'DNA' information in .blend files is a bit more complex but theoretically decouples the format from the exact version of Blender somewhat.
This strikes as a very brittle way for a program to save its state, but here's two successful programs that do it that way, so maybe it's not so bad, after all. It does mean that it's hard for any other program to read those same files, though.
> Though in Factorio's case it's implemented with a lot of `if(saveVersion >= 0.17.123) xyz = stream.readInt32()` and compatibility gets dropped every few versions.
Blender does pretty much this other than dropping compatibility.
If a new feature adds another struct member you also need the ability to read an old file into the new struct. Usually (IIRC) you would just add a line or two to the function which deals with this stuff and set your new field to a sane default -- assuming everything is compatible because all changes aren't, trying to load something like the Big Buck Bunny models into a new version isn't going to work as planned.
The easiest way I found to add something new was to take over some padding bytes (there's a bunch) so the struct has the exact same memory size and the older versions just ignore those.
I think the brittleness is an accepted tradeoff here. In theory, people save many more times than they open a file in this sort of program (and in games), so it's advantageous to make saving a fast path and loading a slow path.
Last I looked at it while working on assimp, this was rather quite outdated. It describes how to get the correct format and how to grep around the codebase giving the overall idea of how the format is just a memory dump, but otherwise was pretty much of little help nowadays.
The downside is the Python API breaks all the time.