Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7960678db | ||
|
|
2d7fac02bb | ||
|
|
1141a4e382 | ||
|
|
392aec8529 | ||
|
|
f9487a3336 | ||
|
|
305f2cc0dd | ||
|
|
bde7a6a279 | ||
|
|
88bbf916b5 | ||
|
|
19bc5c38dc | ||
|
|
2c768ff5d4 | ||
|
|
36f60cbb3c | ||
|
|
356030865a | ||
|
|
4a1cd76cf5 | ||
|
|
2bf960028e |
No files matched your search
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Fix PHP code style issues
|
||||
uses: aglipanci/laravel-pint-action@2.5
|
||||
uses: aglipanci/laravel-pint-action@2.6
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
vendor
|
||||
composer.lock
|
||||
.phpunit.cache
|
||||
build
|
||||
build
|
||||
@@ -1,3 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `laravel-cision-feed` will be documented in this file.
|
||||
|
||||
## v2.1 - 2025-07-04
|
||||
|
||||
**Full Changelog**: https://github.com/MattitjaAB/laravel-cision-feed/compare/v2.0...v2.1
|
||||
|
||||
## v2.0 - 2025-07-04
|
||||
|
||||
### What's Changed
|
||||
|
||||
* 2.0 by @mathiaspalmqvist in https://github.com/MattitjaAB/laravel-cision-feed/pull/1
|
||||
* Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot in https://github.com/MattitjaAB/laravel-cision-feed/pull/2
|
||||
|
||||
### New Contributors
|
||||
|
||||
* @mathiaspalmqvist made their first contribution in https://github.com/MattitjaAB/laravel-cision-feed/pull/1
|
||||
* @dependabot made their first contribution in https://github.com/MattitjaAB/laravel-cision-feed/pull/2
|
||||
|
||||
**Full Changelog**: https://github.com/MattitjaAB/laravel-cision-feed/compare/1.0.2...v2.0
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
];
|
||||
@@ -1,9 +1,9 @@
|
||||
# Laravel Cision Feed
|
||||
|
||||
[](https://packagist.org/packages/mattitjaab/laravel-cision-feed)
|
||||
[](https://packagist.org/packages/mattitja/laravel-cision-feed)
|
||||
[](https://github.com/mattitjaab/laravel-cision-feed/actions?query=workflow%3Arun-tests+branch%3Amain)
|
||||
[](https://github.com/mattitjaab/laravel-cision-feed/actions?query=workflow%3A%22Fix+PHP+code+style+issues%22+branch%3Amain)
|
||||
[](https://packagist.org/packages/mattitjaab/laravel-cision-feed)
|
||||
[](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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+37
-12
@@ -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 = null)
|
||||
{
|
||||
$this->slug = $slug ?? env('LARAVEL_CISION_FEED_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 : '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
-1
@@ -24,10 +24,84 @@ it('can fetch and parse full article content', function () {
|
||||
'news.cision.com/*' => Http::response(file_get_contents(__DIR__.'/Fixtures/article.html')),
|
||||
]);
|
||||
|
||||
$cision = new Cision;
|
||||
$cision = new Cision('test-company');
|
||||
$content = $cision->fetchContent('https://news.cision.com/se/example/r/test,c123456');
|
||||
|
||||
expect($content)->toHaveKeys(['header', 'published_at', 'content']);
|
||||
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'));
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
Reference in new issue
Block a user