<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mentat Logic]]></title><description><![CDATA[Deterministic analysis for scraped social data. Processors, not scrapers.]]></description><link>https://mentatlogic.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Mentat Logic</title><link>https://mentatlogic.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 05 Sep 2026 02:04:26 GMT</lastBuildDate><atom:link href="https://mentatlogic.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Reading a comment section like an analyst: coordination signals without calling anyone a bot]]></title><description><![CDATA[Advertisers ask it after every campaign: "did my budget buy real engagement?" PR teams ask it during every pile-on: "is this organic?" Here is how to read a comment export like an analyst — the signal]]></description><link>https://mentatlogic.hashnode.dev/reading-a-comment-section-like-an-analyst-coordination-signals-without-calling-anyone-a-bot</link><guid isPermaLink="true">https://mentatlogic.hashnode.dev/reading-a-comment-section-like-an-analyst-coordination-signals-without-calling-anyone-a-bot</guid><dc:creator><![CDATA[Mentat Logic]]></dc:creator><pubDate>Fri, 24 Jul 2026 06:00:23 GMT</pubDate><content:encoded><![CDATA[<p>Advertisers ask it after every campaign: "did my budget buy real engagement?" PR teams ask it during every pile-on: "is this organic?" Here is how to read a comment export like an analyst — the signals that mean something, the ones that don't, and the honest limit nobody selling "bot scores" will tell you about.</p>
<h2>First, retire the word "bot"</h2>
<p>Three different things hide behind it, and they leak differently:</p>
<p>Tier 1 — automated accounts. Scripts. They leak automation: machine-even posting intervals, generated-name patterns (Firstname Lastname8123 cohorts), impossible hours held for weeks.</p>
<p>Tier 2 — cheap operated fakes. Real humans, fake accounts, paid per comment. This is the tier that actually floods commercial and political campaigns — and it defeats every automation signal, because the typing is human. What it cannot hide is coordination: the campaign brief synchronizes them. They arrive in bursts, they paraphrase the same talking point, they repeat themselves across posts, they reply to each other to lift the thread.</p>
<p>Tier 3 — professionally managed personas. Full histories, varied interests, natural language. Here is the honest limit: in engagement data alone, these are indistinguishable from real people by design. Anyone selling certainty about this tier is selling vibes.</p>
<h2>The signals worth trusting (each with its evidence)</h2>
<ol>
<li><p>Timing bursts — many distinct accounts commenting on one post inside a tight window. Evidence: the timestamp cluster. One burst is an influencer share; recurring bursts with overlapping membership are a pattern.</p>
</li>
<li><p>Duplicate and near-duplicate text — identical or lightly-edited comments across accounts. Copy-paste with cosmetic edits clusters beautifully.</p>
</li>
<li><p>Talking-point convergence — distinct accounts using the same rare phrase inside a window. Softer than duplication, still leaky.</p>
</li>
<li><p>Cross-post repetition — one account, one text, many posts.</p>
</li>
<li><p>Machine-regular cadence — posting intervals too even for a human (needs several comments per account to mean anything).</p>
</li>
<li><p>Burst-only participation — accounts that exist in your data only inside bursts.</p>
</li>
<li><p>Mutual reply-chains — pairs boosting each other's comments to lift thread ranking.</p>
</li>
</ol>
<h2>The two disciplines that keep the analysis honest</h2>
<p>Insufficient data is an answer. An account with one comment cannot be judged — not "probably fine," not "suspicious." Insufficient signal. Most accounts in most datasets land here, and saying so is what makes the rest of the analysis credible.</p>
<p>Signals describe behaviour, never persons. "This account's comments sit inside two bursts and a duplicate cluster" is evidence. "This account is a bot" is a guess wearing a costume — and a legal exposure if you put it in a client deliverable. Report patterns with evidence attached; let humans decide what to do about them.</p>
<h2>The procedure</h2>
<pre><code class="language-text">
1. Deduplicate comments first (same comment arrives twice via URL variants).

2. Group by account; count; below threshold -&gt; INSUFFICIENT SIGNAL, stop.

3. Detect bursts per post (window + distinct-account minimum).

4. Cluster normalized text (exact, then near-duplicate, then rare-phrase).

5. Check cadence regularity, cross-post repeats, reply-chain pairs.

6. Levels from accumulated evidence: NO SIGNALS / WEAK / MULTIPLE / STRONG.

7. Print into the deliverable: the coverage block, the evidence per flag,

   and what the method CANNOT detect (Tier 3). The limits are the credibility.
</code></pre>
<hr />
<p>Disclosure: I build a tool that runs exactly this — the <a href="https://apify.com/mentat-logic/mentat-authenticity-audit">Engagement Authenticity Audit</a> processes any Facebook comments export ($0.66 per 1,000 unique comments, deduplicated first; free demo with a planted burst you can watch it catch — and a single-comment account you can watch it refuse to judge). Its siblings: the free <a href="https://apify.com/mentat-logic/mentat-resolver">Page Identity Resolver</a> and <a href="https://apify.com/mentat-logic/mentat-page-compare">Page Compare</a>. The procedure above works without any of them — that's rather the point.</p>
]]></content:encoded></item><item><title><![CDATA[The Facebook URL Zoo: every page-link format and how to canonicalize them]]></title><description><![CDATA[If you scrape or analyze Facebook data, the same page will arrive under half a dozen different URLs — and if you count anything before resolving that, your numbers are quietly wrong. This is the refer]]></description><link>https://mentatlogic.hashnode.dev/the-facebook-url-zoo-every-page-link-format-and-how-to-canonicalize-them</link><guid isPermaLink="true">https://mentatlogic.hashnode.dev/the-facebook-url-zoo-every-page-link-format-and-how-to-canonicalize-them</guid><category><![CDATA[web scraping]]></category><category><![CDATA[Facebook]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[analytics]]></category><dc:creator><![CDATA[Mentat Logic]]></dc:creator><pubDate>Tue, 21 Jul 2026 19:42:54 GMT</pubDate><content:encoded><![CDATA[<p><em>If you scrape or analyze Facebook data, the same page will arrive under half a dozen different URLs — and if you count anything before resolving that, your numbers are quietly wrong. This is the reference I wish had existed: the formats, the traps, and a canonicalization procedure that never guesses.</em></p>
<h2>Why your counts are wrong</h2>
<p>Pull posts from any Facebook scraper and look at the page URLs. The same fish market shows up as:</p>
<pre><code class="language-plaintext">https://www.facebook.com/HelensFreshMarket
https://m.facebook.com/helensfreshmarket/
https://www.facebook.com/profile.php?id=100077000000001
https://www.facebook.com/p/Helens-Fresh-Market-100077000000001/
</code></pre>
<p>Group by URL and you have four "pages," each with a quarter of the real engagement. Every downstream number — rankings, share-of-voice, averages — inherits the error. Post URLs have the same disease: the same post arrives with and without tracking parameters, from mobile and desktop hosts, and through share redirects.</p>
<h2>The zoo, part 1: hosts</h2>
<p>All of these serve the same content and must be folded together:</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>www.facebook.com</code></td>
<td>canonical desktop</td>
</tr>
<tr>
<td>facebook.com (bare)</td>
<td>redirects to www</td>
</tr>
<tr>
<td>m.facebook.com</td>
<td>mobile web (very common in scraped data)</td>
</tr>
<tr>
<td>mbasic.facebook.com</td>
<td>legacy low-bandwidth mobile</td>
</tr>
<tr>
<td>web.facebook.com</td>
<td>regional variant (common in Africa/Asia exports)</td>
</tr>
<tr>
<td>touch.facebook.com</td>
<td>legacy touch interface</td>
</tr>
</tbody></table>
<p><strong>Rule 1: fold every host to <a href="http://www.facebook.com">www.facebook.com</a>, force https, drop the trailing slash.</strong> Trivial, and it already collapses a third of the zoo.</p>
<h2>The zoo, part 2: page-identity URLs</h2>
<p>These are the formats that name a page (strongest evidence first):</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>Example</th>
<th>Identity signal</th>
</tr>
</thead>
<tbody><tr>
<td>profile.php?id={N}</td>
<td>/profile.php?id=100077…</td>
<td><strong>numeric ID — authoritative</strong></td>
</tr>
<tr>
<td>/p/{Name}-{N}/</td>
<td>/p/Helens-Fresh-Market-100077…/</td>
<td><strong>numeric ID — authoritative</strong> (newer format; the trailing number is the page ID)</td>
</tr>
<tr>
<td>/people/{Name}/{N}/</td>
<td>/people/Helen-M/100077…/</td>
<td>numeric ID — authoritative</td>
</tr>
<tr>
<td>/pages/{Name}/{N}</td>
<td>legacy</td>
<td>numeric ID — authoritative</td>
</tr>
<tr>
<td>/{vanity}</td>
<td>/HelensFreshMarket</td>
<td>slug — reliable but <strong>case-varies</strong>; case-fold before comparing</td>
</tr>
<tr>
<td>/pg/{vanity}/…</td>
<td>legacy page-tab wrapper</td>
<td>slug after stripping /pg/</td>
</tr>
</tbody></table>
<p><strong>Rule 2: extract identity by hierarchy — a numeric ID always outranks a slug.</strong> When both are observed for the same entity (a slug URL and a profile.php?id= URL that share posts or metadata), the numeric ID is the canonical key and the slug becomes an alias.</p>
<p><strong>Rule 3: slugs are case-insensitive — case-fold before comparing.</strong> HelensFreshMarket and helensfreshmarket are the same page; a case-sensitive groupby splits them.</p>
<h2>The zoo, part 3: post URLs</h2>
<table>
<thead>
<tr>
<th>Format</th>
<th>Example</th>
<th>Note</th>
</tr>
</thead>
<tbody><tr>
<td>/{vanity}/posts/{id}</td>
<td>/helensfreshmarket/posts/101</td>
<td>id may be numeric or an opaque pfbid… token</td>
</tr>
<tr>
<td>permalink.php?story_fbid={S}&amp;id={P}</td>
<td>desktop permalink</td>
<td>the <strong>params are the identity</strong> — keep them</td>
</tr>
<tr>
<td>story.php?story_fbid={S}&amp;id={P}</td>
<td>mobile permalink</td>
<td>same as above</td>
</tr>
<tr>
<td>/groups/{g}/posts/{id}</td>
<td>group post</td>
<td>group ≠ page; keep the distinction</td>
</tr>
<tr>
<td>photo.php?fbid={N} / /photo/?fbid={N}</td>
<td>photo post</td>
<td>fbid is the identity param</td>
</tr>
<tr>
<td>/watch/?v={N}</td>
<td>video watch page</td>
<td><strong>carries no page identity</strong> (see traps)</td>
</tr>
<tr>
<td>/reel/{id}</td>
<td>reel</td>
<td>post-level only</td>
</tr>
<tr>
<td>/share/p/{token}/, /share/v/{token}/</td>
<td>share redirect</td>
<td><strong>opaque</strong> (see traps)</td>
</tr>
</tbody></table>
<p><strong>Rule 4: for deduplication, strip tracking parameters but keep identity parameters.</strong> Strip: ref, refid, fbclid, mibextid, rdid, utm_*, <strong>tn</strong>, comment_id, notif_id, notif_t, locale. Keep: story_fbid, id, fbid, v. The same post with and without ?ref=share&amp;utm=x is one post; the same path with a different story_fbid is not.</p>
<h2>The traps (where guessing corrupts datasets)</h2>
<p><strong>Trap 1 — display names are not identity.</strong> Two pages named "Caladan Bay Tours" can be genuinely different entities (rebrand, fan page, impersonator, franchise). Merging on display name is how datasets rot. The correct behavior is to report the collision as a possible-same-entity hint with the evidence, and let a human decide.</p>
<p><strong>Trap 2 — /watch?v= links carry no page identity.</strong> The URL names a video, not a page. Attribute it to a page only if the row carries separate page metadata; otherwise it is honestly unresolvable.</p>
<p><strong>Trap 3 — share links are opaque.</strong> /share/p/{token}/ is a server-side redirect; the token doesn't encode the destination. Offline, you cannot resolve it — flag it, don't guess it.</p>
<p><strong>Trap 4 — renames.</strong> A page that changed its vanity slug between scrapes, and never exposed its numeric ID, looks like two pages. Without an ID observation there is no safe automatic merge; surface it, don't force it.</p>
<p><strong>Trap 5 — pfbid tokens.</strong> Modern post IDs are opaque pseudonymized tokens. Treat them as exact-match strings only; never parse meaning out of them.</p>
<h2>The procedure, end to end</h2>
<pre><code class="language-text">for each row:
  1. fold host → www.facebook.com, https, strip trailing slash
  2. classify URL: page-identity form vs post form
  3. page identity:
       numeric ID present → key = fb:{numeric}
       else vanity slug   → key = fb:{casefold(slug)}
       else               → UNRESOLVED (keep the URL as evidence)
  4. post dedup key: canonical path + kept identity params
       (tracking params stripped)
  5. merge pages only when keys match
       same display name, different keys → report hint, do NOT merge
</code></pre>
<p>The principle underneath: <strong>an unresolved row with its evidence attached is useful; a silently guessed merge is poison.</strong> Every downstream consumer can act on "here are 3 rows I couldn't resolve, and why" — nobody can undo a wrong merge they never knew happened.</p>
<hr />
<p><em>Disclosure: I maintain a small free tool that implements exactly this procedure (including the refusals) for any posts-scraper export —</em> <a href="https://apify.com/mentat-logic/mentat-resolver"><em>Facebook Page Identity Resolver &amp; URL Deduplicator</em></a> <em>on Apify, with a 3-second no-signup demo. But the procedure above works fine without it — that's rather the point.</em></p>
]]></content:encoded></item></channel></rss>