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(' << Nope Italic sentence. Lead: This is a test article paragraph.
Two
"))->toBe("One
\nTwo
"); +});