From 392aec85293cb2ad918052680cea4b4722ffee01 Mon Sep 17 00:00:00 2001 From: Mathias Palmqvist Date: Wed, 11 Nov 2026 20:37:05 +0100 Subject: [PATCH] Fix packaging, timezone handling and HTML cleaning bugs - Replace symfony 7.4.x-dev constraints with stable ranges so the package installs with minimum-stability stable - Add the Cision facade referenced in composer.json and a config file (slug, timezone); the constructor now falls back to config('cision.slug') - Convert feed timestamps with a real timezone instead of a hardcoded +2h, which was off by an hour during winter time - Return false from fetchContent() when the request fails or the page has no article/heading, instead of throwing - Fix italic paragraph detection, which never matched - Fix literal '\n' in HtmlCleaner whitespace cleanup - Normalize empty feed elements to empty strings - Update README and add tests for the above --- composer.json | 4 +- config/cision.php | 16 ++++++++ readme.md | 25 ++++++++++-- src/Cision.php | 51 ++++++++++++++++++------ src/CisionServiceProvider.php | 13 +++--- src/Facades/Cision.php | 23 +++++++++++ src/Support/HtmlCleaner.php | 22 +++++++++-- tests/CisionTest.php | 74 +++++++++++++++++++++++++++++++++++ tests/HtmlCleanerTest.php | 7 ++++ 9 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 config/cision.php create mode 100644 src/Facades/Cision.php create mode 100644 tests/HtmlCleanerTest.php diff --git a/composer.json b/composer.json index a371fcd..22aa223 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,8 @@ "php": "^8.3", "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", "spatie/laravel-package-tools": "^1.16", - "symfony/css-selector": "7.4.x-dev", - "symfony/dom-crawler": "7.4.x-dev" + "symfony/css-selector": "^6.4 || ^7.0 || ^8.0", + "symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { "orchestra/testbench": "^8.22 || ^9.0 || ^10.0", diff --git a/config/cision.php b/config/cision.php new file mode 100644 index 0000000..4c290e2 --- /dev/null +++ b/config/cision.php @@ -0,0 +1,16 @@ + env('LARAVEL_CISION_FEED_SLUG'), + + /* + * Timezone used when formatting the "created_at" value of feed items. + */ + 'timezone' => env('LARAVEL_CISION_FEED_TIMEZONE', 'Europe/Stockholm'), + +]; diff --git a/readme.md b/readme.md index 4026f90..56d7f18 100644 --- a/readme.md +++ b/readme.md @@ -24,12 +24,20 @@ A Laravel package for retrieving and parsing data from [Cision News](https://new composer require mattitjaab/laravel-cision-feed ``` -You may optionally set your Cision slug in your `.env`: +Set your Cision newsroom slug in your `.env`: ``` LARAVEL_CISION_FEED_SLUG=your-cision-slug ``` +Optionally publish the config file: + +```bash +php artisan vendor:publish --tag="laravel-cision-feed-config" +``` + +Feed timestamps (`created_at`) are converted to `Europe/Stockholm` by default. Override with `LARAVEL_CISION_FEED_TIMEZONE`. + --- ## Usage @@ -37,7 +45,8 @@ LARAVEL_CISION_FEED_SLUG=your-cision-slug ```php use Mattitja\Cision\Cision; -$cision = new Cision(); +$cision = new Cision(); // uses config('cision.slug') +$cision = new Cision('other-company'); // or pass a slug explicitly // Fetch RSS entries $items = $cision->press(); // or ->news(), ->financial(), ->media() @@ -50,6 +59,16 @@ echo $article['published_at']; // ISO timestamp echo $article['content']; // Clean HTML ``` +Or use the facade: + +```php +use Mattitja\Cision\Facades\Cision; + +$items = Cision::press(); +``` + +All methods return `false` if the request fails or the response cannot be parsed. + --- ## Testing @@ -68,7 +87,7 @@ See [CHANGELOG](CHANGELOG.md) for recent changes. ## Contributing -Contributions are welcome! Please see [CONTRIBUTING](CONTRIBUTING.md) for guidelines. +Contributions are welcome! Please open an issue or pull request. --- diff --git a/src/Cision.php b/src/Cision.php index bbdf5e9..49f0ec3 100755 --- a/src/Cision.php +++ b/src/Cision.php @@ -13,11 +13,12 @@ class Cision protected string $slug; /** - * Constructor that accepts a slug identifier for the feed. + * Create a new instance for the given newsroom slug. When no slug is + * given, the "cision.slug" config value is used. */ - public function __construct(string $slug) + public function __construct(?string $slug = null) { - $this->slug = $slug; + $this->slug = (string) ($slug ?? config('cision.slug', '')); } public function all(int $page = 1): array|false @@ -59,15 +60,26 @@ class Cision /** * Fetch and parse the HTML content of a Cision article URL. */ - public function fetchContent(string $url): array + public function fetchContent(string $url): array|false { - $html = Http::get($url)->body(); - $crawler = new Crawler($html); + $response = Http::get($url); + + if ($response->failed() || trim($response->body()) === '') { + return false; + } + + $crawler = new Crawler($response->body()); $article = $crawler->filter('article')->first(); + $heading = $article->filter('h1'); - $header = trim($article->filter('h1')->text()); - $publishedAt = $article->filter('time')->attr('datetime'); + if ($article->count() === 0 || $heading->count() === 0) { + return false; + } + + $header = trim($heading->text()); + $time = $article->filter('time'); + $publishedAt = $time->count() > 0 ? $time->attr('datetime') : null; $allowedTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li']; $allowedInline = ['strong', 'b', 'em', 'i', 'br']; @@ -137,12 +149,16 @@ class Cision $items = isset($items[0]) ? $items : [$items]; // Format the result + $timezone = config('cision.timezone', 'Europe/Stockholm'); + $formatted = array_map(fn ($item) => [ - 'guid' => $item['guid'], - 'title' => $item['title'], - 'description' => $item['description'], - 'link' => $item['link'], - 'created_at' => Carbon::parse($item['pubDate'])->addHours(2)->format('Y-m-d H:i:s'), + 'guid' => $this->stringValue($item['guid'] ?? null), + 'title' => $this->stringValue($item['title'] ?? null), + 'description' => $this->stringValue($item['description'] ?? null), + 'link' => $this->stringValue($item['link'] ?? null), + 'created_at' => Carbon::parse($this->stringValue($item['pubDate'] ?? null)) + ->setTimezone($timezone) + ->format('Y-m-d H:i:s'), ], $items); return [ @@ -151,4 +167,13 @@ class Cision 'next_page' => count($formatted) === 24 ? $page + 1 : false, ]; } + + /** + * Empty XML elements are converted to empty arrays by the JSON round + * trip, so normalize every value to a string. + */ + protected function stringValue(mixed $value): string + { + return is_string($value) ? $value : ''; + } } diff --git a/src/CisionServiceProvider.php b/src/CisionServiceProvider.php index 54cf451..055f80e 100644 --- a/src/CisionServiceProvider.php +++ b/src/CisionServiceProvider.php @@ -9,12 +9,13 @@ class CisionServiceProvider extends PackageServiceProvider { public function configurePackage(Package $package): void { - /* - * This class is a Package Service Provider - * - * More info: https://github.com/spatie/laravel-package-tools - */ $package - ->name('laravel-cision-feed'); + ->name('laravel-cision-feed') + ->hasConfigFile('cision'); + } + + public function packageRegistered(): void + { + $this->app->bind(Cision::class, fn () => new Cision); } } diff --git a/src/Facades/Cision.php b/src/Facades/Cision.php new file mode 100644 index 0000000..cf4f304 --- /dev/null +++ b/src/Facades/Cision.php @@ -0,0 +1,23 @@ +C14N(); - $isItalic = str_starts_with(trim($html), '".trim($inner).'

'; } @@ -88,8 +86,24 @@ class HtmlCleaner $html = str_replace(["\u{A0}", "\t", "\r"], ' ', $html); $html = preg_replace('/[ ]{2,}/', ' ', $html); $html = preg_replace("/(\n\s*){2,}/", "\n", $html); - $html = str_replace([' ', ' \n'], '', $html); + $html = preg_replace('/[ ]+\n/', "\n", $html); return trim($html); } + + /** + * Whether the first meaningful child of the element is an or . + */ + protected static function startsWithItalic(DOMElement $node): bool + { + foreach ($node->childNodes as $child) { + if ($child instanceof DOMText && trim($child->textContent) === '') { + continue; + } + + return $child instanceof DOMElement && in_array($child->nodeName, ['em', 'i'], true); + } + + return false; + } } diff --git a/tests/CisionTest.php b/tests/CisionTest.php index c4f67aa..bd032cd 100644 --- a/tests/CisionTest.php +++ b/tests/CisionTest.php @@ -31,3 +31,77 @@ it('can fetch and parse full article content', function () { expect($content['header'])->not->toBeEmpty(); expect($content['content'])->toContain(' << + + aADL{$date} + + XML; + + Http::fakeSequence() + ->push($rss('Fri, 04 Jul 2025 07:55:00 GMT')) + ->push($rss('Wed, 15 Jan 2025 07:55:00 GMT')); + + $cision = new Cision('test-company'); + + expect($cision->press()['items'][0]['created_at'])->toBe('2025-07-04 09:55:00'); + expect($cision->press()['items'][0]['created_at'])->toBe('2025-01-15 08:55:00'); +}); + +it('returns empty strings for empty feed elements', function () { + Http::fake([ + 'news.cision.com/*' => Http::response(<<<'XML' + + + aALFri, 04 Jul 2025 07:55:00 GMT + + XML), + ]); + + expect((new Cision('test-company'))->press()['items'][0]['description'])->toBe(''); +}); + +it('returns false when the feed request fails', function () { + Http::fake(['news.cision.com/*' => Http::response('', 500)]); + + expect((new Cision('test-company'))->press())->toBeFalse(); +}); + +it('returns false when the article request fails', function () { + Http::fake(['news.cision.com/*' => Http::response('', 404)]); + + expect((new Cision('test-company'))->fetchContent('https://news.cision.com/se/x'))->toBeFalse(); +}); + +it('returns false when the page has no article', function () { + Http::fake(['news.cision.com/*' => Http::response('

Nope

')]); + + expect((new Cision('test-company'))->fetchContent('https://news.cision.com/se/x'))->toBeFalse(); +}); + +it('marks paragraphs that start with italic text', function () { + Http::fake([ + 'news.cision.com/*' => Http::response(file_get_contents(__DIR__.'/Fixtures/article.html')), + ]); + + $content = (new Cision('test-company'))->fetchContent('https://news.cision.com/se/x')['content']; + + expect($content) + ->toContain('

Italic sentence.

') + ->toContain('

Lead: This is a test article paragraph.

') + ->not->toContain('Test Article Header'); +}); + +it('uses the configured slug when none is given', function () { + config()->set('cision.slug', 'configured-company'); + + Http::fake([ + 'news.cision.com/*' => Http::response(file_get_contents(__DIR__.'/Fixtures/rss-press.xml')), + ]); + + Mattitja\Cision\Facades\Cision::press(); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'n=configured-company')); +}); diff --git a/tests/HtmlCleanerTest.php b/tests/HtmlCleanerTest.php new file mode 100644 index 0000000..0d0251f --- /dev/null +++ b/tests/HtmlCleanerTest.php @@ -0,0 +1,7 @@ +One

\n

Two

"))->toBe("

One

\n

Two

"); +});