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
\nTwo
"))->toBe("One
\nTwo
");
+});
--
2.53.0