Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd5feb954 |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Dependabot metadata
|
- name: Dependabot metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: dependabot/fetch-metadata@v2.5.0
|
uses: dependabot/fetch-metadata@v3.1.0
|
||||||
with:
|
with:
|
||||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "mattitja/laravel-cision-feed",
|
"name": "mattitjaab/laravel-cision-feed",
|
||||||
"description": "Fetch and parse Cision press releases via RSS and article HTML in Laravel.",
|
"description": "Fetch and parse Cision press releases via RSS and article HTML in Laravel.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"illuminate/contracts": "^10.0 || ^11.0 || ^12.0",
|
"illuminate/contracts": "^10.0 || ^11.0 || ^12.0",
|
||||||
"spatie/laravel-package-tools": "^1.16",
|
"spatie/laravel-package-tools": "^1.16",
|
||||||
"symfony/css-selector": "^6.4 || ^7.0 || ^8.0",
|
"symfony/css-selector": "7.4.x-dev",
|
||||||
"symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0"
|
"symfony/dom-crawler": "7.4.x-dev"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"orchestra/testbench": "^8.22 || ^9.0 || ^10.0",
|
"orchestra/testbench": "^8.22 || ^9.0 || ^10.0",
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?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
|
# Laravel Cision Feed
|
||||||
|
|
||||||
[](https://packagist.org/packages/mattitja/laravel-cision-feed)
|
[](https://packagist.org/packages/mattitjaab/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%3Arun-tests+branch%3Amain)
|
||||||
[](https://github.com/mattitjaab/laravel-cision-feed/actions?query=workflow%3A%22Fix+PHP+code+style+issues%22+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/mattitja/laravel-cision-feed)
|
[](https://packagist.org/packages/mattitjaab/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.
|
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,23 +21,15 @@ A Laravel package for retrieving and parsing data from [Cision News](https://new
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require mattitja/laravel-cision-feed
|
composer require mattitjaab/laravel-cision-feed
|
||||||
```
|
```
|
||||||
|
|
||||||
Set your Cision newsroom slug in your `.env`:
|
You may optionally set your Cision slug in your `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
LARAVEL_CISION_FEED_SLUG=your-cision-slug
|
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
|
## Usage
|
||||||
@@ -45,8 +37,7 @@ Feed timestamps (`created_at`) are converted to `Europe/Stockholm` by default. O
|
|||||||
```php
|
```php
|
||||||
use Mattitja\Cision\Cision;
|
use Mattitja\Cision\Cision;
|
||||||
|
|
||||||
$cision = new Cision(); // uses config('cision.slug')
|
$cision = new Cision();
|
||||||
$cision = new Cision('other-company'); // or pass a slug explicitly
|
|
||||||
|
|
||||||
// Fetch RSS entries
|
// Fetch RSS entries
|
||||||
$items = $cision->press(); // or ->news(), ->financial(), ->media()
|
$items = $cision->press(); // or ->news(), ->financial(), ->media()
|
||||||
@@ -59,16 +50,6 @@ echo $article['published_at']; // ISO timestamp
|
|||||||
echo $article['content']; // Clean HTML
|
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
|
## Testing
|
||||||
@@ -87,7 +68,7 @@ See [CHANGELOG](CHANGELOG.md) for recent changes.
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please open an issue or pull request.
|
Contributions are welcome! Please see [CONTRIBUTING](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+13
-38
@@ -13,12 +13,11 @@ class Cision
|
|||||||
protected string $slug;
|
protected string $slug;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new instance for the given newsroom slug. When no slug is
|
* Constructor that accepts a slug identifier for the feed.
|
||||||
* given, the "cision.slug" config value is used.
|
|
||||||
*/
|
*/
|
||||||
public function __construct(?string $slug = null)
|
public function __construct(string $slug)
|
||||||
{
|
{
|
||||||
$this->slug = (string) ($slug ?? config('cision.slug', ''));
|
$this->slug = $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function all(int $page = 1): array|false
|
public function all(int $page = 1): array|false
|
||||||
@@ -60,26 +59,15 @@ class Cision
|
|||||||
/**
|
/**
|
||||||
* Fetch and parse the HTML content of a Cision article URL.
|
* Fetch and parse the HTML content of a Cision article URL.
|
||||||
*/
|
*/
|
||||||
public function fetchContent(string $url): array|false
|
public function fetchContent(string $url): array
|
||||||
{
|
{
|
||||||
$response = Http::get($url);
|
$html = Http::get($url)->body();
|
||||||
|
$crawler = new Crawler($html);
|
||||||
if ($response->failed() || trim($response->body()) === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$crawler = new Crawler($response->body());
|
|
||||||
|
|
||||||
$article = $crawler->filter('article')->first();
|
$article = $crawler->filter('article')->first();
|
||||||
$heading = $article->filter('h1');
|
|
||||||
|
|
||||||
if ($article->count() === 0 || $heading->count() === 0) {
|
$header = trim($article->filter('h1')->text());
|
||||||
return false;
|
$publishedAt = $article->filter('time')->attr('datetime');
|
||||||
}
|
|
||||||
|
|
||||||
$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'];
|
$allowedTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li'];
|
||||||
$allowedInline = ['strong', 'b', 'em', 'i', 'br'];
|
$allowedInline = ['strong', 'b', 'em', 'i', 'br'];
|
||||||
@@ -149,16 +137,12 @@ class Cision
|
|||||||
$items = isset($items[0]) ? $items : [$items];
|
$items = isset($items[0]) ? $items : [$items];
|
||||||
|
|
||||||
// Format the result
|
// Format the result
|
||||||
$timezone = config('cision.timezone', 'Europe/Stockholm');
|
|
||||||
|
|
||||||
$formatted = array_map(fn ($item) => [
|
$formatted = array_map(fn ($item) => [
|
||||||
'guid' => $this->stringValue($item['guid'] ?? null),
|
'guid' => $item['guid'],
|
||||||
'title' => $this->stringValue($item['title'] ?? null),
|
'title' => $item['title'],
|
||||||
'description' => $this->stringValue($item['description'] ?? null),
|
'description' => $item['description'],
|
||||||
'link' => $this->stringValue($item['link'] ?? null),
|
'link' => $item['link'],
|
||||||
'created_at' => Carbon::parse($this->stringValue($item['pubDate'] ?? null))
|
'created_at' => Carbon::parse($item['pubDate'])->addHours(2)->format('Y-m-d H:i:s'),
|
||||||
->setTimezone($timezone)
|
|
||||||
->format('Y-m-d H:i:s'),
|
|
||||||
], $items);
|
], $items);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -167,13 +151,4 @@ class Cision
|
|||||||
'next_page' => count($formatted) === 24 ? $page + 1 : false,
|
'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,13 +9,12 @@ class CisionServiceProvider extends PackageServiceProvider
|
|||||||
{
|
{
|
||||||
public function configurePackage(Package $package): void
|
public function configurePackage(Package $package): void
|
||||||
{
|
{
|
||||||
|
/*
|
||||||
|
* This class is a Package Service Provider
|
||||||
|
*
|
||||||
|
* More info: https://github.com/spatie/laravel-package-tools
|
||||||
|
*/
|
||||||
$package
|
$package
|
||||||
->name('laravel-cision-feed')
|
->name('laravel-cision-feed');
|
||||||
->hasConfigFile('cision');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function packageRegistered(): void
|
|
||||||
{
|
|
||||||
$this->app->bind(Cision::class, fn () => new Cision);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?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,7 +50,9 @@ class HtmlCleaner
|
|||||||
|
|
||||||
if (in_array($tag, $allowedTags)) {
|
if (in_array($tag, $allowedTags)) {
|
||||||
if ($tag === 'p') {
|
if ($tag === 'p') {
|
||||||
$class = self::startsWithItalic($node) ? ' class="italic"' : '';
|
$html = $node->C14N();
|
||||||
|
$isItalic = str_starts_with(trim($html), '<em') || str_starts_with(trim($html), '<i');
|
||||||
|
$class = $isItalic ? ' class="italic"' : '';
|
||||||
|
|
||||||
return "<p{$class}>".trim($inner).'</p>';
|
return "<p{$class}>".trim($inner).'</p>';
|
||||||
}
|
}
|
||||||
@@ -86,24 +88,8 @@ class HtmlCleaner
|
|||||||
$html = str_replace(["\u{A0}", "\t", "\r"], ' ', $html);
|
$html = str_replace(["\u{A0}", "\t", "\r"], ' ', $html);
|
||||||
$html = preg_replace('/[ ]{2,}/', ' ', $html);
|
$html = preg_replace('/[ ]{2,}/', ' ', $html);
|
||||||
$html = preg_replace("/(\n\s*){2,}/", "\n", $html);
|
$html = preg_replace("/(\n\s*){2,}/", "\n", $html);
|
||||||
$html = preg_replace('/[ ]+\n/', "\n", $html);
|
$html = str_replace([' ', ' \n'], '', $html);
|
||||||
|
|
||||||
return trim($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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -31,77 +31,3 @@ it('can fetch and parse full article content', function () {
|
|||||||
expect($content['header'])->not->toBeEmpty();
|
expect($content['header'])->not->toBeEmpty();
|
||||||
expect($content['content'])->toContain('<p');
|
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'));
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?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