Author SHA1 Message Date
mathiaspalmqvist a7960678db Merge branch 'main' of git.mattitja.se:559342-9607/laravel-cision-feed
Fix PHP code style issues / php-code-styling (push) Waiting to run
PHPStan / phpstan (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
2026-11-11 20:45:04 +01:00
mathiaspalmqvist 2d7fac02bb Rename package to mattitja/laravel-cision-feed 2026-11-11 20:44:30 +01:00
mathiaspalmqvist 1141a4e382 Merge pull request 'Fix packaging, timezone handling and HTML cleaning bugs' (#11) from fix/bugs-and-packaging into main
Fix PHP code style issues / php-code-styling (push) Waiting to run
PHPStan / phpstan (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
Reviewed-on: #11
2026-11-11 19:43:02 +00:00
mathiaspalmqvist 392aec8529 Fix packaging, timezone handling and HTML cleaning bugs
Fix PHP code style issues / php-code-styling (push) Waiting to run
PHPStan / phpstan (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L11.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.3 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-lowest - ubuntu-latest (push) Waiting to run
run-tests / P8.4 - L12.* - prefer-stable - ubuntu-latest (push) Waiting to run
dependabot-auto-merge / dependabot (pull_request_target) Skipped
- 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
2026-11-11 20:37:05 +01:00
github-actions[bot] f9487a3336 Merge pull request #7 from MattitjaAB/dependabot/github_actions/dependabot/fetch-metadata-2.5.0
Bump dependabot/fetch-metadata from 2.4.0 to 2.5.0
2026-01-12 00:51:29 +00:00
dependabot[bot] 305f2cc0dd Bump dependabot/fetch-metadata from 2.4.0 to 2.5.0
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2.4.0 to 2.5.0.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2.4.0...v2.5.0)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 00:51:18 +00:00
12 changed files with 214 additions and 35 deletions

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.4.0
uses: dependabot/fetch-metadata@v2.5.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
@@ -23,6 +23,6 @@ jobs:
uses: aglipanci/laravel-pint-action@2.6
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7
uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_message: Fix styling
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
release-notes: ${{ github.event.release.body }}
- name: Commit updated CHANGELOG
uses: stefanzweifel/git-auto-commit-action@v7
uses: stefanzweifel/git-auto-commit-action@v6
with:
branch: main
commit_message: Update CHANGELOG
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "mattitjaab/laravel-cision-feed",
"name": "mattitja/laravel-cision-feed",
"description": "Fetch and parse Cision press releases via RSS and article HTML in Laravel.",
"keywords": [
"laravel",
@@ -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",
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
/*
* The Cision newsroom slug, e.g. "example-company" in
* https://news.cision.com/se/example-company
*/
'slug' => env('LARAVEL_CISION_FEED_SLUG'),
/*
* Timezone used when formatting the "created_at" value of feed items.
*/
'timezone' => env('LARAVEL_CISION_FEED_TIMEZONE', 'Europe/Stockholm'),
];
+25 -6
View File
@@ -1,9 +1,9 @@
# Laravel Cision Feed
[![Latest Version on Packagist](https://img.shields.io/packagist/v/mattitjaab/laravel-cision-feed.svg?style=flat-square)](https://packagist.org/packages/mattitjaab/laravel-cision-feed)
[![Latest Version on Packagist](https://img.shields.io/packagist/v/mattitja/laravel-cision-feed.svg?style=flat-square)](https://packagist.org/packages/mattitja/laravel-cision-feed)
[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/mattitjaab/laravel-cision-feed/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/mattitjaab/laravel-cision-feed/actions?query=workflow%3Arun-tests+branch%3Amain)
[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/mattitjaab/laravel-cision-feed/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/mattitjaab/laravel-cision-feed/actions?query=workflow%3A%22Fix+PHP+code+style+issues%22+branch%3Amain)
[![Total Downloads](https://img.shields.io/packagist/dt/mattitjaab/laravel-cision-feed.svg?style=flat-square)](https://packagist.org/packages/mattitjaab/laravel-cision-feed)
[![Total Downloads](https://img.shields.io/packagist/dt/mattitja/laravel-cision-feed.svg?style=flat-square)](https://packagist.org/packages/mattitja/laravel-cision-feed)
A Laravel package for retrieving and parsing data from [Cision News](https://news.cision.com/se). Fetch press releases, financial reports, media posts, and structured article content using a simple and expressive API.
@@ -21,15 +21,23 @@ A Laravel package for retrieving and parsing data from [Cision News](https://new
## Installation
```bash
composer require mattitjaab/laravel-cision-feed
composer require mattitja/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.
---
+38 -13
View File
@@ -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 : '';
}
}
+7 -6
View File
@@ -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);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Mattitja\Cision\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static array|false all(int $page = 1)
* @method static array|false press(int $page = 1)
* @method static array|false financial(int $page = 1)
* @method static array|false news(int $page = 1)
* @method static array|false media(int $page = 1)
* @method static array|false fetchContent(string $url)
*
* @see \Mattitja\Cision\Cision
*/
class Cision extends Facade
{
protected static function getFacadeAccessor(): string
{
return \Mattitja\Cision\Cision::class;
}
}
+18 -4
View File
@@ -50,9 +50,7 @@ class HtmlCleaner
if (in_array($tag, $allowedTags)) {
if ($tag === 'p') {
$html = $node->C14N();
$isItalic = str_starts_with(trim($html), '<em') || str_starts_with(trim($html), '<i');
$class = $isItalic ? ' class="italic"' : '';
$class = self::startsWithItalic($node) ? ' class="italic"' : '';
return "<p{$class}>".trim($inner).'</p>';
}
@@ -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 <em> or <i>.
*/
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;
}
}
+74
View File
@@ -31,3 +31,77 @@ it('can fetch and parse full article content', function () {
expect($content['header'])->not->toBeEmpty();
expect($content['content'])->toContain('<p');
});
it('converts feed timestamps to Stockholm time in summer and winter', function () {
$rss = fn (string $date) => <<<XML
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"><channel>
<item><guid>a</guid><title>A</title><description>D</description><link>L</link><pubDate>{$date}</pubDate></item>
</channel></rss>
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'
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"><channel>
<item><guid>a</guid><title>A</title><description></description><link>L</link><pubDate>Fri, 04 Jul 2025 07:55:00 GMT</pubDate></item>
</channel></rss>
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('<html><body><p>Nope</p></body></html>')]);
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('<p class="italic"><em>Italic sentence.</em></p>')
->toContain('<p><strong>Lead:</strong> This is a test article paragraph.</p>')
->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'));
});
+7
View File
@@ -0,0 +1,7 @@
<?php
use Mattitja\Cision\Support\HtmlCleaner;
it('removes trailing spaces before newlines without joining lines', function () {
expect(HtmlCleaner::clean("<p>One</p> \n<p>Two</p>"))->toBe("<p>One</p>\n<p>Two</p>");
});