Where Facebook hides the permalink

I have a script that posts photos to a Facebook Page from Safari. Publishing works fine. Getting the link back to the post it just made is where everything fell apart.

The goal is small. Publish a post, then grab its address, something like https://www.facebook.com/YourPage/posts/pfbid02w8x..., so it can go into a file that a website reads. A person does this by hovering over the post’s timestamp and copying the link. Three seconds by hand. It took me hours to automate, mostly because I spent them looking in the wrong place.

Five things that do not work

Reading the link off the page. The obvious move is to find the timestamp’s link and read its address. There is no address to read. Facebook builds that link only when your mouse hovers over it. Until a real cursor is sitting there, the link does not exist yet.

Scrolling down to load the post. So render more of the page first. That fails too. Facebook’s timeline only keeps a few posts in the page at a time, and it lives inside its own scrolling box. Scrolling the window does nothing to it. I also sent a dozen real Page Down keypresses. The page stayed at about two kilobytes of text and no posts showed up.

The old mobile site. For years the trick was mbasic.facebook.com, which served simple HTML with real links in it. Meta shut it down. It and m.facebook.com both redirect to the normal site now.

Assuming the page rendered at all. This one cost me the most time and it is the least obvious. Safari does not bother rendering a window that nothing is looking at. My terminal was covering Safari, so the page had loaded but was never drawn. Every check came back nearly empty, and I kept deciding the data was not there. It was. I just had the browser buried behind another window.

My original capture routine. It reloaded the Page, found the newest timestamp, right-clicked it, typed “copy link” to pick that menu item, then read the clipboard. It worked sometimes. It was fighting hover-generated links, lazy loading, and menu timing all at once, so sometimes was the ceiling.

Where the link actually is

The link is on the page the whole time. It is just not anywhere I was looking.

Three things sound similar and are not:

  • The visible text is what you would read aloud off the screen.
  • The links are the ones the browser has actually built into the page.
  • The raw HTML is the complete source of the document, including every script tag and all the text inside them.

I kept checking the first two. Both were empty, so I concluded the data was missing.

Here is the thing about the third one. Facebook does not send you a finished page. It sends a small shell plus a big block of JSON that describes what should eventually appear, and JavaScript builds the page from that block afterward. Think of it as a flat-pack box. The parts list is in the box from the moment it arrives, whether or not anything has been assembled yet. Checking the assembled furniture tells you nothing about what is still in the box.

The size difference makes the mistake obvious in hindsight. The visible text was about two kilobytes. The raw HTML was about nine megabytes. Almost everything was in the parts list, and I had been inspecting the furniture.

So stop asking the page for a link and search the source instead:

document.documentElement.innerHTML.match(/pfbid[A-Za-z0-9]{20,}/g)
Javascript

A post id is the word pfbid followed by a long string of letters and numbers. Asking for at least twenty characters after the prefix is enough to skip the spots where the word pfbid shows up by itself inside Facebook’s own code. Run that on the same page that gave me nothing, and the id comes straight back.

Then comes the part that makes this genuinely useful. Before you scroll anywhere, exactly one id comes back. Facebook only packs the newest post into that first block and fetches older ones as you scroll toward them. That sounds like a limitation. For this job it is the opposite, because the post you just published is the newest one. The single id in the box is the one you want.

Which gives you one rule to follow: grab the link right after publishing, before anything scrolls. Wait until the feed has loaded more posts and you will get a list of ids with no way to tell which is which.

Making sure it is the right post

Everything above rests on one assumption: the newest post is the only one in that first block. It has held every time I have tested it. But it is something I observed, not something Facebook promises.

That matters because of how it would fail. Returning nothing is fine, since the script can just ask me for the link. Returning the wrong link is not. If Facebook ever packs two posts into that first block, the code would happily hand back last week’s post, the URL would look perfectly normal, and it would flow into a live website with nothing to flag it.

The fix is small. The script already knows the caption it just posted, so make it prove the id belongs to that caption. Pick a distinctive plain-text phrase from the caption and check it appears in the same block of JSON. Emoji and punctuation get escaped in there, so ordinary words are the reliable fingerprint. No match, no link.

I tested this three ways:

TestResult
Correct captionReturned the right link in about four seconds
Caption from a different postReturned nothing, and said why
No caption suppliedStill works, so old calls do not break

The middle row is the one worth having. Without it the script cannot tell a right answer from a confident wrong one.

The code

def capture_permalink(expect_text: str | None = None) -> str | None:
    """Get the /posts/pfbid… link for the post just published."""
    import re, time
    # 1. Safari has to be in front, or an unseen window never renders the payload.
    safari_activate()
    time.sleep(1.0)
    # 2. Load the Page fresh so the newest post is the one in the payload.
    osa(f'tell application "Safari" to set URL of current tab of window 1 to "{PAGE_URL}"',
        fatal=False)
    # 3. Search the RAW HTML, not the built page, and drop duplicate ids.
    js = ('(function(){var h=document.documentElement.innerHTML;'
          'var m=h.match(/pfbid[A-Za-z0-9]{20,}/g);'
          'if(!m||!m.length) return "NF";'
          'var u=m.filter(function(v,i){return m.indexOf(v)===i;});'
          'return u[0];})()')
    probe = _probe_phrase(expect_text) if expect_text else None
    # 4. Poll, because the payload lands a few seconds after the load finishes.
    for _ in range(10):
        time.sleep(3)
        out = (osa_js(js) or "").strip()
        if out.startswith("pfbid") and re.fullmatch(r"pfbid[A-Za-z0-9]{20,}", out):
            # 5. Prove this id belongs to the caption we just posted.
            if probe and not _payload_contains(probe):
                return None
            return f"{PAGE_URL.rstrip('/')}/posts/{out}"
    return None
Python

Why each step is there:

Bringing Safari to the front is not politeness, it is required. A buried window never renders, so the payload never arrives and every check looks empty. This one line is what turned “the data is not there” into “the data was always there”.

Reloading the Page guarantees a fresh block containing only the newest post, and means you do not care what state the tab was left in.

Dropping duplicate ids looks pointless when there is only one post, but the same id appears several times in the JSON in different fields. Filtering makes the first result a post rather than whichever field happened to come first.

Polling instead of checking once is the difference between reliable and mostly reliable. Telling Safari to load a URL returns as soon as navigation starts, not when the page is ready. In practice the payload showed up on the first check, about four seconds in, but the loop gives it thirty.

Checking the caption is the safety net described above. It converts a silent wrong answer into an honest failure.

To point this at a different Page, change PAGE_URL. Nothing else is account-specific.

Worth saying plainly

This reads an undocumented blob. It can break the day Facebook changes how it packages that JSON, and there will be no warning when it does. If you have Graph API access with pages_read_engagement, use that instead. It is the supported route and it hands you post ids directly. This technique is for when you are driving a real logged-in browser and the API is not available to you.

Two habits came out of this that apply well beyond Facebook. When a scrape comes back empty, check whether the page actually rendered before deciding the data is missing, because a buried browser window will lie to you convincingly. And when the visible page is empty, search the raw source before giving up. Modern sites ship a lot of data inline that never becomes anything you can see.


Posted

in

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

🧭