__tests__/facebook.test.js
Rama main · réplica de lectura del repositorio original.
/**
* Reading Facebook properly, in code, once.
*
* Evaluating one post used to cost five tool calls and about fifteen thousand tokens — look at
* sixty clickable things, click, read ten thousand characters, scroll, read again. A real run spent
* forty-two calls that way and recorded nothing: everything went on OPERATING Facebook and there
* was nothing left for judging whether a person needs what you sell.
*
* The two things worth pinning here are the ones a model should never be asked to do: work out how
* old a post is from whatever the page happened to write, and decide when a feed has stopped
* loading. Both are loops or parsing, and both are cheap in code and expensive in tokens.
*/
import { describe, it, expect, vi } from 'vitest';
import { ageInDays, readFeed, url } from '../src/sites/facebook.js';
const NOW = Date.parse('2026-08-25T12:00:00Z');
describe('how old is this post', () => {
it('prefers the machine-readable date when the page gives one', () => {
expect(ageInDays({ postedIso: '2026-08-18T12:00:00Z' }, NOW)).toBeCloseTo(7, 1);
});
/* Facebook writes relative times in the language of the page, and this account sees Dutch. */
it.each([
['4 u', 4 / 24], ['4 uur', 4 / 24], ['2 h', 2 / 24],
['3 d', 3], ['3 dagen', 3], ['2 w', 14], ['2 weken', 14],
['12 min', 0], ['gisteren', 1], ['vandaag', 0], ['zojuist', 0],
])('reads %s', (text, expected) => {
expect(ageInDays({ postedText: text }, NOW)).toBeCloseTo(expected, 1);
});
it('reads an absolute date, which is what it writes once a post is old', () => {
expect(ageInDays({ postedText: '9 september 2021' }, NOW)).toBeGreaterThan(1700);
});
/* Facebook omits the year only when it is the current one — so a date that would be in the
future is last year's, not next year's. */
it('reads a bare day and month as the most recent one that has happened', () => {
const age = ageInDays({ postedText: '9 september' }, NOW); // NOW is 25 August
expect(age).toBeGreaterThan(340);
expect(age).toBeLessThan(370);
});
/*
* The most important one. An unknown date must not be guessed: a post wrongly called fresh gets
* answered four years late, and one wrongly called old is silently thrown away.
*/
it('says it does not know rather than guessing', () => {
expect(ageInDays({})).toBeNull();
expect(ageInDays({ postedText: 'Bijgewerkt' })).toBeNull();
});
});
describe('reading a feed until there is nothing new', () => {
/* A page that hands out a fresh batch on each scroll, then starts repeating itself. */
const feedOf = (batches) => {
let i = 0;
return {
evaluate: vi.fn(async () => batches[Math.min(i, batches.length - 1)]),
mouse: { wheel: vi.fn(async () => { i++; }) },
};
};
const post = (n, over = {}) => ({ author: `P${n}`, text: `post number ${n} about a leaking roof`,
url: `https://fb/p/${n}`, postedText: '2 d', ...over });
it('scrolls while posts keep appearing and stops when they stop', async () => {
const page = feedOf([[post(1)], [post(1), post(2)], [post(1), post(2), post(3)],
[post(1), post(2), post(3)]]);
const r = await readFeed(page, { settle: 0, now: () => NOW });
expect(r.posts.map((p) => p.author)).toEqual(['P1', 'P2', 'P3']);
expect(r.stopped).toBe('nothing new was loading');
// It did not keep scrolling into nothing.
expect(page.mouse.wheel.mock.calls.length).toBeLessThan(8);
});
/* Facebook sometimes returns one slow batch, so a single empty round is not the end. */
it('does not give up on one empty round', async () => {
let i = 0;
const page = {
evaluate: async () => (i === 1 ? [post(1)] : i >= 2 ? [post(1), post(2)] : [post(1)]),
mouse: { wheel: async () => { i++; } },
};
const r = await readFeed(page, { settle: 0, now: () => NOW });
expect(r.posts).toHaveLength(2);
});
it('drops posts past the cut-off and says how many', async () => {
const page = feedOf([[post(1), post(2, { postedText: '9 september 2021' })]]);
const r = await readFeed(page, { settle: 0, now: () => NOW });
expect(r.posts.map((p) => p.author)).toEqual(['P1']);
// Counted, not silent: "nothing here" and "everything here was old" mean different things.
expect(r.tooOld).toBe(1);
});
/* An unknown date is not old. Treating it as old would silently discard every post whose date
could not be parsed, which is the failure nobody would ever notice. */
it('keeps a post whose date it could not read', async () => {
const page = feedOf([[post(1, { postedText: '', postedIso: null })]]);
const r = await readFeed(page, { settle: 0, now: () => NOW });
expect(r.posts).toHaveLength(1);
expect(r.posts[0].ageDays).toBeNull();
});
/* Carried between calls, so checking back on a group is cheap instead of a full re-read. */
it('returns only what is new since the last sweep', async () => {
const seen = new Set();
const page1 = feedOf([[post(1), post(2)]]);
await readFeed(page1, { settle: 0, seen, now: () => NOW });
const page2 = feedOf([[post(1), post(2), post(3)]]);
const r = await readFeed(page2, { settle: 0, seen, now: () => NOW });
expect(r.posts.map((p) => p.author)).toEqual(['P3']);
expect(r.skippedSeen).toBeGreaterThan(0);
});
it('never scrolls forever', async () => {
let n = 100;
const page = { evaluate: async () => [post(n++)], mouse: { wheel: async () => {} } };
const r = await readFeed(page, { settle: 0, maxScrolls: 5, now: () => NOW });
expect(r.scrolls).toBeLessThanOrEqual(6);
});
/* An exception in the page reads exactly like a quiet day on Facebook. It must not. */
it('says the page could not be read rather than returning nothing', async () => {
const page = { evaluate: async () => { throw new Error('detached frame'); }, mouse: { wheel: async () => {} } };
const r = await readFeed(page, { settle: 0 });
expect(r.error).toMatch(/could not read this page/);
});
});
describe('the addresses', () => {
it('escapes what people actually search for', () => {
expect(url.searchPosts('wie kent een goede dakdekker?'))
.toBe('https://www.facebook.com/search/posts?q=wie%20kent%20een%20goede%20dakdekker%3F');
});
it('builds a search inside one group', () => {
expect(url.groupSearch('123', 'developer gezocht'))
.toBe('https://www.facebook.com/groups/123/search/?q=developer%20gezocht');
});
});