diff --git a/.gitignore b/.gitignore
index 58008dd4..a6e08b96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ yarn-error.log
/coverage-report*
/coverage.xml
/.php-cs-fixer.dist.php
+/.php-cs-fixer.cache
diff --git a/app/Services/Parsers/BelgaArticlePageParser.php b/app/Services/Parsers/BelgaArticlePageParser.php
index 3c1fdc44..955bd51a 100644
--- a/app/Services/Parsers/BelgaArticlePageParser.php
+++ b/app/Services/Parsers/BelgaArticlePageParser.php
@@ -110,12 +110,12 @@ public static function extractThumbnail(string $html): ?string
{
// Try OpenGraph image first
if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) {
- return $matches[1];
+ return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
}
return null;
diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php
new file mode 100644
index 00000000..b783620a
--- /dev/null
+++ b/app/Services/Parsers/BelgaHomepageParser.php
@@ -0,0 +1,28 @@
+
+ */
+ public static function extractArticleUrls(string $json): array
+ {
+ $decoded = json_decode($json, true);
+
+ if (! is_array($decoded) || ! isset($decoded['data']) || ! is_array($decoded['data'])) {
+ return [];
+ }
+
+ return collect($decoded['data'])
+ ->pluck('id')
+ ->filter(fn ($id) => is_int($id) || (is_string($id) && ctype_digit($id)))
+ ->map(fn ($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id))
+ ->unique()
+ ->values()
+ ->toArray();
+ }
+}
diff --git a/app/Services/Parsers/BelgaHomepageParserAdapter.php b/app/Services/Parsers/BelgaHomepageParserAdapter.php
new file mode 100644
index 00000000..0adb6750
--- /dev/null
+++ b/app/Services/Parsers/BelgaHomepageParserAdapter.php
@@ -0,0 +1,39 @@
+url,
+ * seeded from that config value — so drift here would go unnoticed.
+ */
+ private const API_URL = 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN';
+
+ public function __construct(
+ private string $language = 'en',
+ ) {}
+
+ public function canParse(string $url): bool
+ {
+ return str_contains($url, 'belganewsagency.eu') || str_contains($url, 'capi.belga.press');
+ }
+
+ public function extractArticleUrls(string $html): array
+ {
+ return BelgaHomepageParser::extractArticleUrls($html);
+ }
+
+ public function getHomepageUrl(): string
+ {
+ return str_replace('language=EN', 'language='.strtoupper($this->language), self::API_URL);
+ }
+
+ public function getSourceName(): string
+ {
+ return 'Belga News Agency';
+ }
+}
diff --git a/app/Services/Parsers/GuardianArticlePageParser.php b/app/Services/Parsers/GuardianArticlePageParser.php
index 4d462115..09c7c4fa 100644
--- a/app/Services/Parsers/GuardianArticlePageParser.php
+++ b/app/Services/Parsers/GuardianArticlePageParser.php
@@ -68,12 +68,12 @@ public static function extractThumbnail(string $html): ?string
{
// Try OpenGraph image first
if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) {
- return $matches[1];
+ return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
}
return null;
diff --git a/app/Services/Parsers/VrtArticlePageParser.php b/app/Services/Parsers/VrtArticlePageParser.php
index e312bb6f..095c7389 100644
--- a/app/Services/Parsers/VrtArticlePageParser.php
+++ b/app/Services/Parsers/VrtArticlePageParser.php
@@ -67,11 +67,11 @@ public static function extractFullArticle(string $html): ?string
public static function extractThumbnail(string $html): ?string
{
if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) {
- return $matches[1];
+ return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
}
return null;
diff --git a/config/app.php b/config/app.php
index 324b513a..e924bd75 100644
--- a/config/app.php
+++ b/config/app.php
@@ -13,7 +13,7 @@
|
*/
- 'name' => env('APP_NAME', 'Laravel'),
+ 'name' => 'Fedi Feed Router',
/*
|--------------------------------------------------------------------------
diff --git a/config/feed.php b/config/feed.php
index 4215761c..0d82739e 100644
--- a/config/feed.php
+++ b/config/feed.php
@@ -2,6 +2,7 @@
use App\Services\Parsers\BelgaArticlePageParser;
use App\Services\Parsers\BelgaArticleParser;
+use App\Services\Parsers\BelgaHomepageParserAdapter;
use App\Services\Parsers\GuardianArticlePageParser;
use App\Services\Parsers\GuardianArticleParser;
use App\Services\Parsers\VrtArticlePageParser;
@@ -41,12 +42,13 @@
'code' => 'belga',
'name' => 'Belga News Agency',
'description' => 'Belgian national news agency',
- 'type' => 'rss',
+ 'type' => 'website',
'is_active' => true,
'languages' => [
- 'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
+ 'en' => ['url' => 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN'],
],
'parsers' => [
+ 'homepage' => BelgaHomepageParserAdapter::class,
'article' => BelgaArticleParser::class,
'article_page' => BelgaArticlePageParser::class,
],
diff --git a/pint.json b/pint.json
new file mode 100644
index 00000000..93061b6b
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,3 @@
+{
+ "preset": "laravel"
+}
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 30d77eda..6239a493 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -5,7 +5,7 @@
-
{{ config('app.name', 'FFR') }}
+ {{ config('app.name') }}
diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php
index 469c4c32..9bef59e2 100644
--- a/resources/views/layouts/guest.blade.php
+++ b/resources/views/layouts/guest.blade.php
@@ -5,7 +5,7 @@
- {{ config('app.name', 'FFR') }}
+ {{ config('app.name') }}
diff --git a/resources/views/layouts/onboarding.blade.php b/resources/views/layouts/onboarding.blade.php
index 875e9eb0..b43f53c8 100644
--- a/resources/views/layouts/onboarding.blade.php
+++ b/resources/views/layouts/onboarding.blade.php
@@ -5,7 +5,7 @@
- {{ config('app.name', 'FFR') }} - Setup
+ {{ config('app.name') }} - Setup
diff --git a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php
index 0acac2b4..5bee7c96 100644
--- a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php
+++ b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php
@@ -92,22 +92,24 @@ public function test_store_creates_belga_feed_successfully(): void
$response = $this->postJson('/api/v1/feeds', $feedData);
+ $expectedUrl = config('feed.providers.belga.languages.en.url');
+
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Feed created successfully!',
'data' => [
'name' => 'Belga Test Feed',
- 'url' => 'https://www.belganewsagency.eu/feed',
- 'type' => 'rss',
+ 'url' => $expectedUrl,
+ 'type' => 'website',
'is_active' => true,
],
]);
$this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed',
- 'url' => 'https://www.belganewsagency.eu/feed',
- 'type' => 'rss',
+ 'url' => $expectedUrl,
+ 'type' => 'website',
]);
}
diff --git a/tests/Fixtures/belga-pressreleases.json b/tests/Fixtures/belga-pressreleases.json
new file mode 100644
index 00000000..4ebba6b9
--- /dev/null
+++ b/tests/Fixtures/belga-pressreleases.json
@@ -0,0 +1 @@
+{"data":[{"id":35285,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"FIFA cancels plans for World Cup commercialisation after fierce criticism","lead":"FIFA president Gianni Infantino has scrapped the controversial plan to sell shares of the FIFA World Cup to private investors. This was reported by British media, including Sky News and BBC, during the night from Friday to Saturday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:181511382:full?v=6a6c82d8&m=cdodnjha","categories":[{"id":3,"name":"SPORTS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:181511382:full?v=6a6c82d8&m=cdodnjha","publishDate":"2026-08-01T06:24:19"},{"id":35282,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Excluding Spain from Schengen is \"in no way a solution\" for Ceuta, says Belgian FM Prévot","lead":"For minister of Foreign Affairs Maxime Prévot, a temporary exclusion of Spain from the Schengen Area is not a solution. He stated this on Friday in an additional response to the migration crisis in Ceuta, after more than 60,000 people crossed the border between Morocco and the Spanish exclave in recent days.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182478454:full?v=6a6c82d8&m=anpkocdi","categories":[{"id":9,"name":"POLITICS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182478454:full?v=6a6c82d8&m=anpkocdi","publishDate":"2026-07-31T16:59:26"},{"id":35281,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Belgium offers tankers and other vehicles for fire fighting to France","lead":"In the fight against the devastating forest fires that have destroyed thousands of hectares in France over the past few weeks, primarily in the Gironde region, Belgium has expanded its offer of assistance. Our country is ready to send water tankers should France request them, the office of minister of the Interior Bernard Quintin (MR) announced on Friday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182396536:full?v=6a6c82d8&m=fmbijool","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182396536:full?v=6a6c82d8&m=fmbijool","publishDate":"2026-07-31T16:52:04"},{"id":35271,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Brussels Region demands withdrawal of shared scooters and bikes by 1 September","lead":"All shared scooters and bikes in free-floating fleets must be removed from the Brussels streets by no later than 1 September. This was announced by Brussels Mobility on Thursday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171547:full?v=6a6c82d8&m=gnegeckj","categories":[{"id":9,"name":"POLITICS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171547:full?v=6a6c82d8&m=gnegeckj","publishDate":"2026-07-31T06:44:12"},{"id":35270,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Canadian intern at SHAPE suspected of espionage for China","lead":"The Belgian authorities suspect a Canadian woman who was interning at NATO's military headquarters in Bergen (SHAPE) of working for China. Two anonymous sources familiar with the matter told Reuters.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171621:full?v=6a6c82d8&m=hkkippeg","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171621:full?v=6a6c82d8&m=hkkippeg","publishDate":"2026-07-31T06:37:12"},{"id":35269,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Hamas confirms disarmament of group and Israeli withdrawal from Gaza","lead":"Representatives of Hamas have confirmed an agreement on the second phase of the ceasefire with Israel during the night from Thursday to Friday. This was reported to the French news agency AFP by two anonymous sources within Hamas following President Donald Trump's announcement of the agreement. The new phase includes, among other things, the disarmament of the extremist Palestinian organisation and the gradual withdrawal of the Israeli military from the Gaza Strip. The Israeli government has not yet responded to the agreement.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182283582:full?v=6a6c82d8&m=pgklpfdh","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182283582:full?v=6a6c82d8&m=pgklpfdh","publishDate":"2026-07-31T06:11:52"}],"_links":{"next":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=7&count=6&search=&start=&end=&newsroomId=70&language=EN","prev":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=-5&count=6&search=&start=&end=&newsroomId=70&language=EN","self":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN"},"_meta":{"total":14650}}
\ No newline at end of file
diff --git a/tests/Unit/Actions/CreateFeedActionTest.php b/tests/Unit/Actions/CreateFeedActionTest.php
index accb3f54..001b7472 100644
--- a/tests/Unit/Actions/CreateFeedActionTest.php
+++ b/tests/Unit/Actions/CreateFeedActionTest.php
@@ -42,8 +42,9 @@ public function test_creates_belga_feed_with_correct_url(): void
$feed = $this->action->execute('Belga News', 'belga', $language->id);
- $this->assertEquals('https://www.belganewsagency.eu/feed', $feed->url);
- $this->assertEquals('rss', $feed->type);
+ $this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $feed->url);
+ $this->assertStringContainsString('newsroomId=70', $feed->url);
+ $this->assertEquals('website', $feed->type);
$this->assertEquals('belga', $feed->provider);
$this->assertNull($feed->description);
}
diff --git a/tests/Unit/Services/ArticleFetcherBelgaTest.php b/tests/Unit/Services/ArticleFetcherBelgaTest.php
new file mode 100644
index 00000000..8442dc05
--- /dev/null
+++ b/tests/Unit/Services/ArticleFetcherBelgaTest.php
@@ -0,0 +1,100 @@
+assertNotFalse($contents, 'Belga fixture could not be read.');
+
+ return $contents;
+ }
+
+ private function belgaFeed(): Feed
+ {
+ $language = Language::factory()->create(['short_code' => 'en']);
+
+ return Feed::factory()->create([
+ 'type' => 'website',
+ 'provider' => 'belga',
+ 'language_id' => $language->id,
+ 'url' => config('feed.providers.belga.languages.en.url'),
+ ]);
+ }
+
+ public function test_creates_articles_from_belga_api_response(): void
+ {
+ Http::fake(['*' => Http::response($this->apiResponse(), 200)]);
+
+ $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed());
+
+ $this->assertCount(6, $result);
+ $this->assertDatabaseHas('articles', [
+ 'url' => 'https://www.belganewsagency.eu/press-releases/35285/',
+ ]);
+ $this->assertDatabaseHas('articles', [
+ 'url' => 'https://www.belganewsagency.eu/press-releases/35269/',
+ ]);
+ }
+
+ public function test_associates_created_articles_with_the_feed(): void
+ {
+ Http::fake(['*' => Http::response($this->apiResponse(), 200)]);
+
+ $feed = $this->belgaFeed();
+
+ $this->createArticleFetcher()->getArticlesFromFeed($feed);
+
+ $this->assertDatabaseHas('articles', [
+ 'url' => 'https://www.belganewsagency.eu/press-releases/35285/',
+ 'feed_id' => $feed->id,
+ ]);
+ }
+
+ public function test_returns_empty_collection_when_api_returns_no_articles(): void
+ {
+ // A well-formed envelope with nothing in it is legitimate (quiet day),
+ // and must be a no-op rather than an error.
+ Http::fake(['*' => Http::response('{"data":[],"_meta":{"total":0}}', 200)]);
+
+ $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed());
+
+ $this->assertEmpty($result);
+ $this->assertDatabaseCount('articles', 0);
+ }
+
+ public function test_returns_empty_collection_when_api_returns_an_error_page(): void
+ {
+ // The failure mode that caused #115: a 404 HTML body reaching the parser.
+ Http::fake(['*' => Http::response('404 Not Found', 200)]);
+
+ $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed());
+
+ $this->assertEmpty($result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php
index 83a78baf..4774419d 100644
--- a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php
+++ b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php
@@ -242,6 +242,36 @@ public function test_extract_thumbnail_prefers_og_image(): void
$this->assertEquals('https://example.com/og-image.jpg', $thumbnail);
}
+ public function test_extract_thumbnail_decodes_html_entities_in_og_image(): void
+ {
+ // Real Belga CDN URLs carry query params, so the & in the raw HTML
+ // must be decoded or the URL Lemmy receives is malformed. See #117.
+ $html = '';
+
+ $thumbnail = BelgaArticlePageParser::extractThumbnail($html);
+
+ $this->assertEquals('https://cdn.example.com/img:12345:full?v=6a6c828f&m=cdodnjha', $thumbnail);
+ $this->assertStringNotContainsString('&', (string) $thumbnail);
+ }
+
+ public function test_extract_thumbnail_decodes_html_entities_in_img_tag(): void
+ {
+ $html = '';
+
+ $thumbnail = BelgaArticlePageParser::extractThumbnail($html);
+
+ $this->assertEquals('https://cdn.example.com/pic.png?a=1&b=2', $thumbnail);
+ }
+
+ public function test_extract_data_returns_decoded_thumbnail(): void
+ {
+ $html = '';
+
+ $data = BelgaArticlePageParser::extractData($html);
+
+ $this->assertEquals('https://cdn.example.com/x.jpg?v=1&m=2', $data['thumbnail']);
+ }
+
public function test_extract_thumbnail_returns_null_when_not_found(): void
{
$html = '