Merge pull request #99 from trypostit/feat/rss-multiformat-simplepie
feat(automations): multi-format RSS/Atom feeds with dynamic variables
This commit is contained in:
commit
7d52ea72c5
31 changed files with 1116 additions and 95 deletions
|
|
@ -89,12 +89,12 @@ #### Structure
|
|||
|
||||
```markdown
|
||||
---
|
||||
subject: "Changelog: TryPost <version> — <theme 1>, <theme 2>, <theme 3>..."
|
||||
subject: "Changelog <version> — <theme 1>, <theme 2>, <theme 3>..."
|
||||
---
|
||||
|
||||
# Changelog: TryPost <version> — <theme 1>, <theme 2>, <theme 3>...
|
||||
# Changelog <version> — <theme 1>, <theme 2>, <theme 3>...
|
||||
|
||||
By Paulo Castellano • Release <version>
|
||||
By TryPost Product Team • [Release <version>](https://github.com/<OWNER>/<REPO>/releases/tag/<version>)
|
||||
|
||||
Hello! Welcome to this week's update. Here's what's new in TryPost.
|
||||
|
||||
|
|
@ -121,9 +121,11 @@ ## Fixes
|
|||
- <...>
|
||||
|
||||
Cheers,
|
||||
Paulo Castellano from TryPost.it
|
||||
Paulo from TryPost.it
|
||||
```
|
||||
|
||||
**Always link the GitHub release from the byline** — make `Release <version>` a link to `https://github.com/<OWNER>/<REPO>/releases/tag/<version>` (as shown above). It gives developer-minded readers the raw PR-level changelog without cluttering the body.
|
||||
|
||||
#### Theme grouping (AI clusters by user impact)
|
||||
|
||||
Read all commits since the last tag and cluster into **2-3 user-facing themes**. Use whatever frame makes the changes feel coherent to a customer, not to a developer.
|
||||
|
|
@ -159,7 +161,9 @@ #### Bullet rules for "New features" / "Fixes"
|
|||
|
||||
#### Subject line
|
||||
|
||||
Pattern: `Changelog: TryPost <version> — <theme 1>, <theme 2>, <theme 3>...`
|
||||
Pattern: `Changelog <version> — <theme 1>, <theme 2>, <theme 3>...`
|
||||
|
||||
Don't put "TryPost" in the subject — the email already comes from the TryPost sender, so it's redundant.
|
||||
|
||||
Cap around 80 chars. If themes don't fit, shorten to the 2 most impactful + "and more...".
|
||||
|
||||
|
|
@ -167,13 +171,13 @@ ### Step 5 — Humanize the email prose
|
|||
|
||||
Run the email body through the `humanizer` skill before previewing:
|
||||
|
||||
1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo Castellano from TryPost.it' signature."*
|
||||
1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo from TryPost.it' signature."*
|
||||
2. Replace the draft email body with the humanized version.
|
||||
|
||||
**Do NOT humanize:**
|
||||
- The changelog from Step 3 (flat commit list, no prose).
|
||||
- The subject line frontmatter.
|
||||
- The literal signature `Cheers,\nPaulo Castellano from TryPost.it` — keep it exact.
|
||||
- The literal signature `Cheers,\nPaulo from TryPost.it` — keep it exact.
|
||||
|
||||
The humanizer skill itself covers all patterns. Trust it.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
use App\Models\AutomationNodeState;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Automation\FeedParser;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
|
|
@ -40,6 +40,7 @@ public function __construct(
|
|||
private ExpressionResolver $resolver,
|
||||
private SafeHttpFetcher $safeHttp,
|
||||
private AdvanceAutomationRun $advance,
|
||||
private FeedParser $parser,
|
||||
) {}
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
|
|
@ -59,15 +60,15 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
]);
|
||||
}
|
||||
|
||||
$response = Http::get($feedUrl);
|
||||
$response = Http::timeout(10)->get($feedUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = new SimpleXMLElement($response->body());
|
||||
} catch (Throwable) {
|
||||
$items = $this->parser->parse($response->body());
|
||||
|
||||
if ($items === null) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed'));
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
? CarbonImmutable::createFromTimestamp(0)
|
||||
: $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
||||
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($items, $watermark);
|
||||
|
||||
if ($state !== null && $newestSeen !== null) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
|
|
@ -95,58 +96,66 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
|
||||
}
|
||||
|
||||
$first = array_shift($newItems);
|
||||
$total = count($newItems);
|
||||
|
||||
if (! $isPreview) {
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
// A preview (manual/dry test) surfaces ONE item and never fans out, so it
|
||||
// shows the newest item — what the user expects to test against. A real run
|
||||
// takes the oldest new item and fans the rest out as siblings, preserving
|
||||
// feed chronology across branches (items are sorted oldest-first).
|
||||
if ($isPreview) {
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => $total, 'spawned' => 0],
|
||||
'fetched' => end($newItems),
|
||||
]);
|
||||
}
|
||||
|
||||
$first = array_shift($newItems);
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)],
|
||||
'fetch' => ['count' => $total, 'spawned' => count($newItems)],
|
||||
'fetched' => $first,
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array
|
||||
/**
|
||||
* @param list<array<string, mixed>> $parsed Normalized items from FeedParser.
|
||||
* @return array{0: list<array<string, mixed>>, 1: ?CarbonImmutable}
|
||||
*/
|
||||
private function collectNewItems(array $parsed, CarbonImmutable $watermark): array
|
||||
{
|
||||
$items = [];
|
||||
$newestSeen = null;
|
||||
|
||||
foreach ($xml->channel->item ?? [] as $item) {
|
||||
$key = (string) ($item->guid ?? $item->link);
|
||||
foreach ($parsed as $item) {
|
||||
$key = (string) data_get($item, 'key', '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pubDate = $this->parsePubDate((string) $item->pubDate);
|
||||
if ($pubDate === null) {
|
||||
$date = $this->parsePubDate((string) data_get($item, 'date', ''));
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) {
|
||||
$newestSeen = $pubDate;
|
||||
if ($newestSeen === null || $date->greaterThan($newestSeen)) {
|
||||
$newestSeen = $date;
|
||||
}
|
||||
|
||||
if (! $pubDate->greaterThan($watermark)) {
|
||||
if (! $date->greaterThan($watermark)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'_pubDate' => $pubDate,
|
||||
'key' => $key,
|
||||
'title' => (string) $item->title,
|
||||
'link' => (string) $item->link,
|
||||
'description' => (string) $item->description,
|
||||
'pubDate' => (string) $item->pubDate,
|
||||
];
|
||||
$item['_sort'] = $date->getTimestamp();
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
// Process oldest-first so siblings inherit a stable order matching feed chronology.
|
||||
usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp());
|
||||
usort($items, fn ($a, $b) => $a['_sort'] <=> $b['_sort']);
|
||||
|
||||
// Drop the internal sort key — downstream nodes shouldn't see it.
|
||||
$items = array_map(function (array $item): array {
|
||||
unset($item['_pubDate']);
|
||||
unset($item['_sort']);
|
||||
|
||||
return $item;
|
||||
}, $items);
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@
|
|||
use App\Actions\Automation\Run\TestAutomation;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\InspectFeedRequest;
|
||||
use App\Http\Requests\App\Automations\PauseAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\RetryRunRequest;
|
||||
use App\Http\Requests\App\Automations\StoreAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\TestAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\UpdateAutomationRequest;
|
||||
use App\Http\Resources\App\Automation\FeedInspectionResource;
|
||||
use App\Http\Resources\App\PlatformConfigResource;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Http\Resources\AutomationInvocationResource;
|
||||
|
|
@ -31,11 +33,16 @@
|
|||
use App\Models\Automation;
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Automation\FeedParser;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class AutomationController extends Controller
|
||||
{
|
||||
|
|
@ -203,6 +210,38 @@ public function test(TestAutomationRequest $request, Automation $automation, Tes
|
|||
return response()->json(['run_id' => $run->id]);
|
||||
}
|
||||
|
||||
public function inspectFeed(
|
||||
InspectFeedRequest $request,
|
||||
Automation $automation,
|
||||
ExpressionResolver $resolver,
|
||||
SafeHttpFetcher $safeHttp,
|
||||
FeedParser $parser,
|
||||
): JsonResponse|FeedInspectionResource {
|
||||
$this->authorize('update', $automation);
|
||||
|
||||
$feedUrl = $resolver->resolve(
|
||||
(string) $request->validated('feed_url'),
|
||||
['variables' => $automation->resolvedVariables()],
|
||||
);
|
||||
|
||||
// SafeHttpFetcher::get() bundles the SSRF guard, a request timeout, a
|
||||
// redirect cap and a branded user-agent — so a slow or hostile feed can't
|
||||
// hang this synchronous request. It throws on SSRF, timeout or non-2xx.
|
||||
try {
|
||||
$response = $safeHttp->get($feedUrl);
|
||||
} catch (RuntimeException) {
|
||||
return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$items = $parser->parse($response->body());
|
||||
|
||||
if ($items === null) {
|
||||
return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return new FeedInspectionResource($items[0] ?? []);
|
||||
}
|
||||
|
||||
public function showRun(Automation $automation, AutomationRun $run): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $automation);
|
||||
|
|
|
|||
25
app/Http/Requests/App/Automations/InspectFeedRequest.php
Normal file
25
app/Http/Requests/App/Automations/InspectFeedRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InspectFeedRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'feed_url' => ['required', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Enums\Automation\Publish\Mode as PublishMode;
|
||||
use App\Enums\Automation\ScheduleField;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\Rules\ResolvableUrl;
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use App\Services\Automation\GenerateNodeValidator;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
|
|
@ -129,10 +130,13 @@ private function dataRulesForNodeType(?string $type, int $i): array
|
|||
'schedule_timezone' => ['sometimes', 'string', 'timezone'],
|
||||
],
|
||||
NodeType::FetchRss->value => [
|
||||
'feed_url' => ['required', 'url'],
|
||||
'feed_url' => ['required', new ResolvableUrl],
|
||||
'discovered_fields' => ['sometimes', 'array'],
|
||||
'discovered_fields.*.path' => ['required', 'string'],
|
||||
'discovered_fields.*.sample' => ['nullable', 'string'],
|
||||
],
|
||||
NodeType::HttpRequest->value => [
|
||||
'url' => ['required', 'url'],
|
||||
'url' => ['required', new ResolvableUrl],
|
||||
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
|
||||
'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))],
|
||||
'auth_token' => ['nullable', 'string'],
|
||||
|
|
@ -167,7 +171,7 @@ private function dataRulesForNodeType(?string $type, int $i): array
|
|||
'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'],
|
||||
],
|
||||
NodeType::Webhook->value => [
|
||||
'url' => ['required', 'url'],
|
||||
'url' => ['required', new ResolvableUrl],
|
||||
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
|
||||
'payload_template' => ['nullable', 'string'],
|
||||
'headers' => ['nullable', 'array'],
|
||||
|
|
|
|||
69
app/Http/Resources/App/Automation/FeedInspectionResource.php
Normal file
69
app/Http/Resources/App/Automation/FeedInspectionResource.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App\Automation;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Flattens the first parsed feed item into a catalog of `{{ fetched.* }}` paths
|
||||
* with sample values, so the editor can offer them as expression completions.
|
||||
*
|
||||
* @property array<string, mixed> $resource The first normalized feed item.
|
||||
*/
|
||||
class FeedInspectionResource extends JsonResource
|
||||
{
|
||||
private const SAMPLE_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* @return array{fields: list<array{path: string, sample: string}>}
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'fields' => $this->flatten((array) $this->resource),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return list<array{path: string, sample: string}>
|
||||
*/
|
||||
private function flatten(array $item, string $prefix = 'fetched'): array
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach ($item as $key => $value) {
|
||||
$path = "{$prefix}.{$key}";
|
||||
|
||||
if (is_array($value) && $this->isAssoc($value)) {
|
||||
$fields = array_merge($fields, $this->flatten($value, $path));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = ['path' => $path, 'sample' => $this->sample($value)];
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $value
|
||||
*/
|
||||
private function isAssoc(array $value): bool
|
||||
{
|
||||
return $value !== [] && ! array_is_list($value);
|
||||
}
|
||||
|
||||
private function sample(mixed $value): string
|
||||
{
|
||||
$text = is_array($value)
|
||||
? (json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '')
|
||||
: (string) $value;
|
||||
|
||||
return str($text)->squish()->limit(self::SAMPLE_MAX_LENGTH)->value();
|
||||
}
|
||||
}
|
||||
40
app/Rules/ResolvableUrl.php
Normal file
40
app/Rules/ResolvableUrl.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* Validates a URL that may contain `{{ ... }}` workflow-variable expressions.
|
||||
* The expressions are resolved at runtime, so they're replaced with a placeholder
|
||||
* before checking the result is a valid URL — letting users template the host or
|
||||
* query (e.g. `https://host/feed.xml?channel_id={{ variables.CHANNEL_ID }}`).
|
||||
*/
|
||||
class ResolvableUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail('validation.url')->translate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value);
|
||||
$scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
|
||||
|
||||
// Require a real http(s) URL once expressions are substituted, so the rule
|
||||
// is no weaker than the plain `url` rule it replaces (rejects file://,
|
||||
// javascript://, etc.) while still allowing templated hosts/queries.
|
||||
if (filter_var($candidate, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) {
|
||||
$fail('validation.url')->translate();
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/Services/Automation/FeedParser.php
Normal file
185
app/Services/Automation/FeedParser.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Automation;
|
||||
|
||||
use SimplePie\Item;
|
||||
use SimplePie\SimplePie;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Parses an RSS 2.0 / Atom 1.0 / RDF feed body into normalized item arrays.
|
||||
*
|
||||
* Each item carries two merged layers:
|
||||
* - **Aliases**: stable cross-format keys (title, link, date, content, author, …)
|
||||
* resolved through SimplePie's normalized getters, so a downstream node works the
|
||||
* same whether the feed is RSS or Atom.
|
||||
* - **Raw**: every namespaced extension tag (yt:videoId, media:thumbnail,
|
||||
* dc:creator, content:encoded, …) flattened with a `prefix_localname` convention,
|
||||
* so feed-specific data stays reachable via `{{ fetched.<field> }}`.
|
||||
*
|
||||
* Aliases win on key collisions. The HTTP fetch and SSRF guard stay in the caller;
|
||||
* the body is handed to SimplePie via `set_raw_data()` so the SSRF guard isn't bypassed.
|
||||
*/
|
||||
class FeedParser
|
||||
{
|
||||
/**
|
||||
* Namespace URI → short prefix for the raw layer.
|
||||
*/
|
||||
private const NS_PREFIX = [
|
||||
'http://search.yahoo.com/mrss/' => 'media',
|
||||
'http://www.youtube.com/xml/schemas/2015' => 'yt',
|
||||
'http://purl.org/dc/elements/1.1/' => 'dc',
|
||||
'http://purl.org/dc/terms/' => 'dc',
|
||||
'http://purl.org/rss/1.0/modules/content/' => 'content',
|
||||
'http://www.itunes.com/dtds/podcast-1.0.dtd' => 'itunes',
|
||||
'https://podcastindex.org/namespace/1.0' => 'podcast',
|
||||
'http://purl.org/rss/1.0/modules/slash/' => 'slash',
|
||||
'http://wellformedweb.org/CommentAPI/' => 'wfw',
|
||||
'http://www.georss.org/georss' => 'georss',
|
||||
];
|
||||
|
||||
/**
|
||||
* Namespaces treated as the feed "core" — their tags get no prefix, so RSS and
|
||||
* Atom land on the same raw key names (and are then overridden by aliases).
|
||||
*/
|
||||
private const NS_CORE = [
|
||||
'',
|
||||
'http://www.w3.org/2005/Atom',
|
||||
'http://purl.org/rss/1.0/',
|
||||
'http://backend.userland.com/rss2',
|
||||
'http://my.netscape.com/rdf/simple/0.9/',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>|null Null when the body is not a usable feed
|
||||
* (invalid XML, or any parse failure).
|
||||
*/
|
||||
public function parse(string $body): ?array
|
||||
{
|
||||
try {
|
||||
$feed = new SimplePie;
|
||||
$feed->enable_cache(false);
|
||||
$feed->set_raw_data($body);
|
||||
|
||||
if (! @$feed->init()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items());
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalize(Item $item): array
|
||||
{
|
||||
$enclosure = $item->get_enclosure();
|
||||
|
||||
$aliases = [
|
||||
'key' => $item->get_id(false, false) ?: $item->get_permalink(),
|
||||
'title' => $item->get_title(),
|
||||
'link' => $item->get_permalink(),
|
||||
'date' => $item->get_date('c'),
|
||||
'pubDate' => $item->get_date('c'),
|
||||
'content' => $item->get_content(),
|
||||
'description' => $item->get_description(),
|
||||
'author' => $item->get_author()?->get_name(),
|
||||
'id' => $item->get_id(false, false),
|
||||
'image' => $enclosure?->get_thumbnail() ?: null,
|
||||
'categories' => array_values(array_filter(array_map(
|
||||
fn ($category) => $category->get_label(),
|
||||
$item->get_categories() ?? [],
|
||||
))),
|
||||
'enclosure' => $enclosure === null ? null : array_filter([
|
||||
'url' => $enclosure->get_link(),
|
||||
'type' => $enclosure->get_type(),
|
||||
'length' => $enclosure->get_length(),
|
||||
], fn ($value) => $value !== null),
|
||||
];
|
||||
|
||||
// Aliases win on collision, so merge them over the raw layer.
|
||||
return array_merge($this->rawFields($item), $aliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rawFields(Item $item): array
|
||||
{
|
||||
return $this->flattenChildren((array) data_get($item->data, 'child', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $children Namespace-URI keyed tag map.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function flattenChildren(array $children): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($children as $namespace => $tags) {
|
||||
$prefix = $this->prefixFor((string) $namespace);
|
||||
|
||||
foreach ($tags as $tag => $nodes) {
|
||||
$key = $prefix === '' ? (string) $tag : "{$prefix}_{$tag}";
|
||||
$values = array_map(fn ($node) => $this->flattenNode((array) $node), $nodes);
|
||||
$out[$key] = count($values) === 1 ? $values[0] : $values;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function flattenNode(array $node): mixed
|
||||
{
|
||||
$attribs = $this->attributes($node);
|
||||
$children = (array) data_get($node, 'child', []);
|
||||
|
||||
if ($children !== []) {
|
||||
return array_merge($this->flattenChildren($children), $attribs);
|
||||
}
|
||||
|
||||
$text = trim((string) data_get($node, 'data', ''));
|
||||
|
||||
return match (true) {
|
||||
$text !== '' && $attribs === [] => $text,
|
||||
$text === '' && $attribs !== [] => $attribs,
|
||||
$text !== '' && $attribs !== [] => array_merge(['_text' => $text], $attribs),
|
||||
default => $text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function attributes(array $node): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ((array) data_get($node, 'attribs', []) as $attrs) {
|
||||
foreach ((array) $attrs as $name => $value) {
|
||||
$out[(string) $name] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function prefixFor(string $namespace): string
|
||||
{
|
||||
if (in_array($namespace, self::NS_CORE, true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::NS_PREFIX[$namespace] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"posthog/posthog-php": "^4.1",
|
||||
"predis/predis": "^3.3",
|
||||
"sendkit/sendkit-laravel": "^1.1",
|
||||
"simplepie/simplepie": "^1.9",
|
||||
"socialiteproviders/facebook": "^4.1",
|
||||
"socialiteproviders/instagram": "^5.1",
|
||||
"socialiteproviders/linkedin": "^5.0",
|
||||
|
|
|
|||
83
composer.lock
generated
83
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4d71722b650e74b41e1943d3ae9c814f",
|
||||
"content-hash": "19849eb01cb4d8f93733666b53444c5a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -7402,6 +7402,87 @@
|
|||
},
|
||||
"time": "2026-03-20T00:53:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplepie/simplepie",
|
||||
"version": "1.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/simplepie/simplepie.git",
|
||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-pcre": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"php": ">=7.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"donatj/mock-webserver": "^2.7",
|
||||
"friendsofphp/php-cs-fixer": "^2.19 || ^3.8",
|
||||
"mf2/mf2": "^0.5.0",
|
||||
"phpstan/phpstan": "~1.12.2",
|
||||
"phpunit/phpunit": "^8 || ^9 || ^10",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/simple-cache": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "",
|
||||
"ext-iconv": "",
|
||||
"ext-intl": "",
|
||||
"ext-mbstring": "",
|
||||
"mf2/mf2": "Microformat module that allows for parsing HTML for microformats"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"SimplePie": "library"
|
||||
},
|
||||
"psr-4": {
|
||||
"SimplePie\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ryan Parman",
|
||||
"homepage": "http://ryanparman.com/",
|
||||
"role": "Creator, alumnus developer"
|
||||
},
|
||||
{
|
||||
"name": "Sam Sneddon",
|
||||
"homepage": "https://gsnedders.com/",
|
||||
"role": "Alumnus developer"
|
||||
},
|
||||
{
|
||||
"name": "Ryan McCue",
|
||||
"email": "me@ryanmccue.info",
|
||||
"homepage": "http://ryanmccue.info/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple Atom/RSS parsing library for PHP",
|
||||
"homepage": "http://simplepie.org/",
|
||||
"keywords": [
|
||||
"atom",
|
||||
"feeds",
|
||||
"rss"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/simplepie/simplepie/issues",
|
||||
"source": "https://github.com/simplepie/simplepie/tree/1.9.0"
|
||||
},
|
||||
"time": "2025-09-12T06:34:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "socialiteproviders/facebook",
|
||||
"version": "4.1.0",
|
||||
|
|
|
|||
|
|
@ -144,7 +144,13 @@
|
|||
'trigger_post_published_at' => 'When the post was published',
|
||||
'fetched_title' => 'Fetched item title',
|
||||
'fetched_link' => 'Fetched item link',
|
||||
'fetched_description' => 'Fetched item description',
|
||||
'fetched_date' => 'Fetched item publish date',
|
||||
'fetched_content' => 'Fetched item full content',
|
||||
'fetched_description' => 'Fetched item summary',
|
||||
'fetched_author' => 'Fetched item author',
|
||||
'fetched_image' => 'Fetched item image URL',
|
||||
'fetched_categories' => 'Fetched item categories',
|
||||
'fetched_enclosure' => 'Fetched item media (audio/video/file)',
|
||||
'fetched_pubdate' => 'Fetched item publish date',
|
||||
'fetched_http' => 'Fetched HTTP item (append a field)',
|
||||
'generated_content' => 'AI-generated post content',
|
||||
|
|
@ -330,6 +336,12 @@
|
|||
'fetch_rss' => [
|
||||
'feed_url' => 'Feed URL',
|
||||
'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.',
|
||||
'inspect' => 'Inspect feed',
|
||||
'inspecting' => 'Inspecting…',
|
||||
'inspect_hint' => 'Fetch a sample to discover the available fields for use in downstream nodes.',
|
||||
'inspect_error' => 'Could not read this feed. Check the URL and try again.',
|
||||
'discovered_fields' => 'Available fields',
|
||||
'discovered_empty' => 'No fields found in the latest item.',
|
||||
],
|
||||
'http_request' => [
|
||||
'url' => 'URL',
|
||||
|
|
|
|||
|
|
@ -144,7 +144,13 @@
|
|||
'trigger_post_published_at' => 'Cuándo se publicó el post',
|
||||
'fetched_title' => 'Título del ítem obtenido',
|
||||
'fetched_link' => 'Enlace del ítem obtenido',
|
||||
'fetched_description' => 'Descripción del ítem obtenido',
|
||||
'fetched_date' => 'Fecha de publicación del ítem obtenido',
|
||||
'fetched_content' => 'Contenido completo del ítem obtenido',
|
||||
'fetched_description' => 'Resumen del ítem obtenido',
|
||||
'fetched_author' => 'Autor del ítem obtenido',
|
||||
'fetched_image' => 'URL de la imagen del ítem obtenido',
|
||||
'fetched_categories' => 'Categorías del ítem obtenido',
|
||||
'fetched_enclosure' => 'Multimedia del ítem (audio/vídeo/archivo)',
|
||||
'fetched_pubdate' => 'Fecha de publicación del ítem obtenido',
|
||||
'fetched_http' => 'Ítem HTTP obtenido (añade un campo)',
|
||||
'generated_content' => 'Contenido del post generado por IA',
|
||||
|
|
@ -330,6 +336,12 @@
|
|||
'fetch_rss' => [
|
||||
'feed_url' => 'URL del feed',
|
||||
'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.',
|
||||
'inspect' => 'Inspeccionar feed',
|
||||
'inspecting' => 'Inspeccionando…',
|
||||
'inspect_hint' => 'Obtén una muestra para descubrir los campos disponibles para usar en los siguientes nodos.',
|
||||
'inspect_error' => 'No se pudo leer este feed. Revisa la URL e inténtalo de nuevo.',
|
||||
'discovered_fields' => 'Campos disponibles',
|
||||
'discovered_empty' => 'No se encontraron campos en el último ítem.',
|
||||
],
|
||||
'http_request' => [
|
||||
'url' => 'URL',
|
||||
|
|
|
|||
|
|
@ -144,7 +144,13 @@
|
|||
'trigger_post_published_at' => 'Quando o post foi publicado',
|
||||
'fetched_title' => 'Título do item buscado',
|
||||
'fetched_link' => 'Link do item buscado',
|
||||
'fetched_description' => 'Descrição do item buscado',
|
||||
'fetched_date' => 'Data de publicação do item buscado',
|
||||
'fetched_content' => 'Conteúdo completo do item buscado',
|
||||
'fetched_description' => 'Resumo do item buscado',
|
||||
'fetched_author' => 'Autor do item buscado',
|
||||
'fetched_image' => 'URL da imagem do item buscado',
|
||||
'fetched_categories' => 'Categorias do item buscado',
|
||||
'fetched_enclosure' => 'Mídia do item (áudio/vídeo/arquivo)',
|
||||
'fetched_pubdate' => 'Data de publicação do item buscado',
|
||||
'fetched_http' => 'Item HTTP buscado (adicione um campo)',
|
||||
'generated_content' => 'Conteúdo do post gerado por IA',
|
||||
|
|
@ -330,6 +336,12 @@
|
|||
'fetch_rss' => [
|
||||
'feed_url' => 'URL do feed',
|
||||
'feed_url_hint' => 'Na primeira execução, o watermark é setado pra "agora" pra não inundar os próximos nós com items históricos. Execuções seguintes só veem items novos.',
|
||||
'inspect' => 'Inspecionar feed',
|
||||
'inspecting' => 'Inspecionando…',
|
||||
'inspect_hint' => 'Busca uma amostra pra descobrir os campos disponíveis pra usar nos próximos nós.',
|
||||
'inspect_error' => 'Não foi possível ler esse feed. Confira a URL e tente de novo.',
|
||||
'discovered_fields' => 'Campos disponíveis',
|
||||
'discovered_empty' => 'Nenhum campo encontrado no último item.',
|
||||
],
|
||||
'http_request' => [
|
||||
'url' => 'URL',
|
||||
|
|
|
|||
24
releases/v1.0.2/changelog.md
Normal file
24
releases/v1.0.2/changelog.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
## What's Changed
|
||||
* Update GitHub funding username by @paulocastellano in https://github.com/trypostit/trypost/pull/67
|
||||
* Update star history section for dark and light themes by @paulocastellano in https://github.com/trypostit/trypost/pull/75
|
||||
* Fix workspace name-only update rejected by required brand fields by @paulocastellano in https://github.com/trypostit/trypost/pull/77
|
||||
* Fix calendar showing "no content" for every post by @paulocastellano in https://github.com/trypostit/trypost/pull/78
|
||||
* Stop persisting instagram_carousel as a content type by @paulocastellano in https://github.com/trypostit/trypost/pull/80
|
||||
* fix: Updates symfony packages from 8.0.x to 8.1.0 to address the following security advisories: by @sebastian-works in https://github.com/trypostit/trypost/pull/76
|
||||
* fix(tiktok): align UX strings and render processing notice for app review by @paulocastellano in https://github.com/trypostit/trypost/pull/71
|
||||
* Add Copilot code review instructions by @paulocastellano in https://github.com/trypostit/trypost/pull/85
|
||||
* Aspect ratio selection for Facebook posts (like Instagram) by @paulocastellano in https://github.com/trypostit/trypost/pull/82
|
||||
* Post platform meta (aspect ratio) parity across API and MCP by @paulocastellano in https://github.com/trypostit/trypost/pull/86
|
||||
* fix(linkedin): drop deprecated r_basicprofile from default scopes by @Falconiere in https://github.com/trypostit/trypost/pull/68
|
||||
* Rename post editor 'Schedule' tab to 'Channels' by @paulocastellano in https://github.com/trypostit/trypost/pull/87
|
||||
* Automations ⚙️ by @paulocastellano in https://github.com/trypostit/trypost/pull/88
|
||||
* Fix: resolve Bluesky @mention handles to DIDs (mentions caused "Invalid post data") by @Schrall in https://github.com/trypostit/trypost/pull/93
|
||||
* Harden BlueskyPublisher: link facets, conventions, NSID lexicon, tests by @paulocastellano in https://github.com/trypostit/trypost/pull/94
|
||||
* Add Telegram publishing channel by @paulocastellano in https://github.com/trypostit/trypost/pull/95
|
||||
* Past-due access, reliable checkout.completed, and complete morph map by @paulocastellano in https://github.com/trypostit/trypost/pull/96
|
||||
|
||||
## New Contributors
|
||||
* @Falconiere made their first contribution in https://github.com/trypostit/trypost/pull/68
|
||||
* @Schrall made their first contribution in https://github.com/trypostit/trypost/pull/93
|
||||
|
||||
**Full Changelog**: https://github.com/trypostit/trypost/compare/v1.0.1...v1.0.2
|
||||
47
releases/v1.0.2/email.md
Normal file
47
releases/v1.0.2/email.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
subject: "Changelog v1.0.2 — We're on Discord, plus Automations & Telegram"
|
||||
---
|
||||
|
||||
# Changelog v1.0.2 — We're on Discord, plus Automations & Telegram
|
||||
|
||||
By TryPost Product Team • [Release v1.0.2](https://github.com/trypostit/trypost/releases/tag/v1.0.2)
|
||||
|
||||
Hey — quick rundown of what shipped this week. But first, the thing I'm most excited about:
|
||||
|
||||
## We have a Discord now
|
||||
|
||||
We just opened the TryPost community on Discord. It's where we'll share what we're building, take your feature requests, and help each other get more out of the tool. I'll be in there myself, so come say hi and tell me what you want us to build next.
|
||||
|
||||
**[Join us on Discord →](https://trypost.it/discord)**
|
||||
|
||||
Or just head to [trypost.it/discord](https://trypost.it/discord)
|
||||
|
||||
## Automations
|
||||
|
||||
You can now hand the posting off to TryPost. Point an automation at a source like an RSS feed, and it'll draft a post in your brand voice and schedule it to your channels on whatever cadence you set. There's a visual editor where you wire up the trigger, conditions, and steps, and tabs to watch each run and see what it actually did.
|
||||
|
||||
## Telegram
|
||||
|
||||
Telegram is a channel now. Add our bot to your channel or group, drop in the one-time connect code, and you're publishing text and media albums. You'll also see per-post reactions and your subscriber count next to the rest of your analytics.
|
||||
|
||||
## Sharper posts on every network
|
||||
|
||||
Facebook posts let you pick an aspect ratio now, the same way Instagram does, so your images aren't cropped in ways you didn't choose. And on Bluesky, @mentions and links finally resolve properly — tagging someone no longer throws an "invalid post" error.
|
||||
|
||||
## New features
|
||||
|
||||
- Build automations that draft and schedule posts for you
|
||||
- Connect Telegram channels and groups, with reactions and subscriber stats
|
||||
- Pick the aspect ratio for Facebook image posts
|
||||
- Mention people and add links on Bluesky
|
||||
|
||||
## Fixes
|
||||
|
||||
- The calendar no longer shows "no content" on posts that clearly have content
|
||||
- Renaming a workspace doesn't make you re-fill brand details anymore
|
||||
- If a payment fails, you keep your access and can just update your card instead of starting a new subscription
|
||||
- The editor's "Schedule" tab is now called "Channels", which is what it always was
|
||||
- A handful of TikTok and Instagram posting fixes
|
||||
|
||||
Cheers,
|
||||
Paulo from TryPost.it
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconLoader2, IconRefresh } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { inject, ref, watch } from 'vue';
|
||||
|
||||
import { inspectFeed } from '@/actions/App/Http/Controllers/App/AutomationController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface DiscoveredField {
|
||||
path: string;
|
||||
sample: string;
|
||||
}
|
||||
|
||||
interface FetchRssConfig {
|
||||
feed_url: string;
|
||||
discovered_fields: DiscoveredField[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -15,20 +26,77 @@ const props = defineProps<{
|
|||
}>();
|
||||
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
|
||||
|
||||
const automationId = inject<string>('automationId', '');
|
||||
|
||||
const local = ref<FetchRssConfig>({
|
||||
feed_url: (props.data.feed_url as string) ?? '',
|
||||
discovered_fields: (props.data.discovered_fields as DiscoveredField[]) ?? [],
|
||||
});
|
||||
|
||||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
||||
// Changing the feed invalidates the discovered fields — they belong to the old
|
||||
// feed and would otherwise keep being offered as stale autocomplete suggestions.
|
||||
watch(
|
||||
() => local.value.feed_url,
|
||||
() => {
|
||||
local.value.discovered_fields = [];
|
||||
},
|
||||
);
|
||||
|
||||
const inspectHttp = useHttp<{ feed_url: string }, { fields: DiscoveredField[] }>({ feed_url: '' });
|
||||
const isInspecting = ref(false);
|
||||
const inspectError = ref<string | null>(null);
|
||||
|
||||
const expression = (path: string): string => `{{ ${path} }}`;
|
||||
|
||||
const inspect = async () => {
|
||||
if (isInspecting.value || local.value.feed_url.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
isInspecting.value = true;
|
||||
inspectError.value = null;
|
||||
|
||||
try {
|
||||
inspectHttp.feed_url = local.value.feed_url;
|
||||
const { fields } = await inspectHttp.post(inspectFeed.url(automationId));
|
||||
local.value.discovered_fields = fields;
|
||||
} catch {
|
||||
inspectError.value = trans('automations.config.fetch_rss.inspect_error');
|
||||
} finally {
|
||||
isInspecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label class="mb-1 block">{{ $t('automations.config.fetch_rss.feed_url') }}</Label>
|
||||
<Input v-model="local.feed_url" placeholder="https://example.com/feed.xml" />
|
||||
<InputError :message="errors?.feed_url" class="mt-1" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.fetch_rss.feed_url_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="isInspecting || local.feed_url.trim() === ''" @click="inspect">
|
||||
<IconLoader2 v-if="isInspecting" class="size-4 animate-spin" />
|
||||
<IconRefresh v-else class="size-4" />
|
||||
{{ isInspecting ? $t('automations.config.fetch_rss.inspecting') : $t('automations.config.fetch_rss.inspect') }}
|
||||
</Button>
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.fetch_rss.inspect_hint') }}</p>
|
||||
<InputError v-if="inspectError" :message="inspectError" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div v-if="local.discovered_fields.length > 0">
|
||||
<Label class="mb-1 block">{{ $t('automations.config.fetch_rss.discovered_fields') }}</Label>
|
||||
<ul class="max-h-48 space-y-1.5 overflow-y-auto rounded-md border border-border p-2">
|
||||
<li v-for="field in local.discovered_fields" :key="field.path" class="flex flex-col gap-0.5">
|
||||
<code class="text-xs text-foreground">{{ expression(field.path) }}</code>
|
||||
<span v-if="field.sample" class="truncate text-xs text-foreground/50">{{ field.sample }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -43,13 +43,29 @@ const providedBy = (node: GraphNode): ExpressionSuggestion[] => {
|
|||
}
|
||||
return [suggestion('trigger.event', 'trigger_event'), suggestion('trigger.fired_at', 'trigger_fired_at')];
|
||||
}
|
||||
case 'fetch_rss':
|
||||
return [
|
||||
case 'fetch_rss': {
|
||||
const aliases = [
|
||||
suggestion('fetched.title', 'fetched_title'),
|
||||
suggestion('fetched.link', 'fetched_link'),
|
||||
suggestion('fetched.date', 'fetched_date'),
|
||||
suggestion('fetched.content', 'fetched_content'),
|
||||
suggestion('fetched.description', 'fetched_description'),
|
||||
suggestion('fetched.pubDate', 'fetched_pubdate'),
|
||||
suggestion('fetched.author', 'fetched_author'),
|
||||
suggestion('fetched.image', 'fetched_image'),
|
||||
suggestion('fetched.categories', 'fetched_categories'),
|
||||
suggestion('fetched.enclosure', 'fetched_enclosure'),
|
||||
];
|
||||
// Fields discovered by inspecting the real feed — the dynamic raw layer
|
||||
// (yt_videoId, media_group.*, dc_creator, …) that aliases can't enumerate.
|
||||
const discovered = Array.isArray(node.data?.discovered_fields)
|
||||
? (node.data.discovered_fields as Array<{ path: string; sample?: string }>).map(
|
||||
(field): ExpressionSuggestion => ({ label: field.path, info: field.sample ?? '' }),
|
||||
)
|
||||
: [];
|
||||
const known = new Set(aliases.map((alias) => alias.label));
|
||||
|
||||
return [...aliases, ...discovered.filter((field) => !known.has(field.label))];
|
||||
}
|
||||
case 'http_request':
|
||||
return [suggestion('fetched', 'fetched_http')];
|
||||
case 'generate':
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ provide(
|
|||
'automationExpressionCompletions',
|
||||
computed(() => buildExpressionCatalog(selectedNodeId.value, nodes.value, edges.value, variables.value)),
|
||||
);
|
||||
provide('automationId', props.automation.id);
|
||||
|
||||
// Toggled by an expandable CodeEditor (see CodeEditor.vue) to slide out the
|
||||
// side-by-side editing panel, pushing the sidebar left to make room.
|
||||
|
|
@ -205,6 +206,11 @@ onConnect((connection: Connection) => {
|
|||
let dragStartPosition: XYPosition | null = null;
|
||||
onNodeDragStart(({ node }) => {
|
||||
dragStartPosition = { ...node.position };
|
||||
// A click that moves more than Vue Flow's drag threshold (1px) registers as a
|
||||
// drag, not a click, so onNodeClick never fires. Select here too, otherwise
|
||||
// switching between nodes intermittently fails to open the grabbed node's config.
|
||||
selectedNodeId.value = node.id;
|
||||
selectedEdgeId.value = null;
|
||||
});
|
||||
onNodeDragStop(({ node }) => {
|
||||
if (!dragStartPosition) return;
|
||||
|
|
@ -482,6 +488,7 @@ const defaultEdgeOptions = {
|
|||
:default-edge-options="defaultEdgeOptions"
|
||||
:connection-mode="ConnectionMode.Loose"
|
||||
:delete-key-code="null"
|
||||
:node-drag-threshold="5"
|
||||
:snap-to-grid="true"
|
||||
:snap-grid="[16, 16]"
|
||||
fit-view-on-init
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@
|
|||
Route::post('automations/{automation}/pause', [AutomationController::class, 'pause'])->name('app.automations.pause');
|
||||
Route::post('automations/{automation}/runs/{run}/retry', [AutomationController::class, 'retryRun'])->name('app.automations.runs.retry');
|
||||
Route::post('automations/{automation}/test', [AutomationController::class, 'test'])->name('app.automations.test');
|
||||
Route::post('automations/{automation}/feed/inspect', [AutomationController::class, 'inspectFeed'])->name('app.automations.feed.inspect');
|
||||
Route::get('automations/{automation}/runs/{run}', [AutomationController::class, 'showRun'])->name('app.automations.runs.show');
|
||||
|
||||
// API Keys
|
||||
|
|
|
|||
192
tests/Feature/Automation/FeedInspectionTest.php
Normal file
192
tests/Feature/Automation/FeedInspectionTest.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
||||
it('returns the discovered fields for a feed', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.automations.feed.inspect', $automation->id), [
|
||||
'feed_url' => 'https://1.1.1.1/feed.xml',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$paths = collect($response->json('fields'))->pluck('path');
|
||||
expect($paths)->toContain('fetched.title')
|
||||
->toContain('fetched.link')
|
||||
->toContain('fetched.yt_videoId')
|
||||
->toContain('fetched.media_group.media_thumbnail.url');
|
||||
});
|
||||
|
||||
it('resolves workspace variables in the feed url before fetching', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->for($this->workspace)->create([
|
||||
'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.feed.inspect', $automation->id), [
|
||||
'feed_url' => 'https://{{ variables.HOST }}/feed.xml',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '1.1.1.1/feed.xml'));
|
||||
});
|
||||
|
||||
it('returns 422 for a malformed feed', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response('not a feed', 200)]);
|
||||
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.feed.inspect', $automation->id), [
|
||||
'feed_url' => 'https://1.1.1.1/feed.xml',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('validates that feed_url is required', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.feed.inspect', $automation->id), [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrorFor('feed_url');
|
||||
});
|
||||
|
||||
it('persists discovered_fields stored on a fetch_rss node through an update', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'fetch_1',
|
||||
'type' => 'fetch_rss',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => [
|
||||
'feed_url' => 'https://example.com/feed.xml',
|
||||
'discovered_fields' => [
|
||||
['path' => 'fetched.yt_videoId', 'sample' => 'abc123'],
|
||||
],
|
||||
],
|
||||
]],
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$node = collect($automation->fresh()->nodes)->firstWhere('id', 'fetch_1');
|
||||
expect(data_get($node, 'data.discovered_fields.0.path'))->toBe('fetched.yt_videoId');
|
||||
});
|
||||
|
||||
it('accepts a feed_url containing a workflow-variable expression', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'fetch_1',
|
||||
'type' => 'fetch_rss',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => ['feed_url' => 'https://www.youtube.com/feeds/videos.xml?channel_id={{ variables.CHANNEL_ID }}'],
|
||||
]],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
it('rejects a feed_url that is not a URL even after stripping expressions', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'fetch_1',
|
||||
'type' => 'fetch_rss',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => ['feed_url' => 'not a url {{ variables.X }}'],
|
||||
]],
|
||||
])
|
||||
->assertSessionHasErrors('nodes.0.data.feed_url');
|
||||
});
|
||||
|
||||
it('rejects a non-http(s) scheme in feed_url', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'fetch_1',
|
||||
'type' => 'fetch_rss',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => ['feed_url' => 'file:///etc/passwd'],
|
||||
]],
|
||||
])
|
||||
->assertSessionHasErrors('nodes.0.data.feed_url');
|
||||
});
|
||||
|
||||
it('accepts a templated url on an http_request node', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'http_1',
|
||||
'type' => 'http_request',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => [
|
||||
'url' => 'https://api.example.com/{{ variables.PATH }}/feed',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
],
|
||||
]],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
it('accepts a templated url on a webhook node', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [[
|
||||
'id' => 'webhook_1',
|
||||
'type' => 'webhook',
|
||||
'position' => ['x' => 0, 'y' => 0],
|
||||
'data' => [
|
||||
'url' => 'https://hooks.example.com/{{ variables.TOKEN }}',
|
||||
'method' => 'POST',
|
||||
],
|
||||
]],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
it('forbids inspecting a feed for an automation in another workspace', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response('<rss version="2.0"><channel></channel></rss>', 200)]);
|
||||
|
||||
$automation = Automation::factory()->for(Workspace::factory()->create())->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.feed.inspect', $automation->id), [
|
||||
'feed_url' => 'https://1.1.1.1/feed.xml',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -15,37 +15,11 @@
|
|||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
const FETCH_RSS_OLD = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Ancient</title><link>https://1.1.1.1/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Old</title><link>https://1.1.1.1/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
const FETCH_RSS_MIXED = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Older</title><link>https://1.1.1.1/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer1</title><link>https://1.1.1.1/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer2</title><link>https://1.1.1.1/c</link><guid>c</guid><pubDate>Mon, 15 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
const FETCH_RSS_THREE_NEW = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>New1</title><link>https://1.1.1.1/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New2</title><link>https://1.1.1.1/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New3</title><link>https://1.1.1.1/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
beforeEach(fn () => Bus::fake());
|
||||
|
||||
it('first execution dispatches nothing and stores watermark from newest item', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_OLD, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_old'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -74,7 +48,7 @@
|
|||
|
||||
it('processes the first new item on the current run and spawns siblings for the rest', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -111,7 +85,7 @@
|
|||
|
||||
it('spawns sibling runs down the item edge for each remaining new item', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -151,7 +125,7 @@
|
|||
|
||||
it('fans every spawned item out across all branches wired to the fetch node', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -185,7 +159,7 @@
|
|||
|
||||
it('does not persist the production watermark on a manual real-data test', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -219,6 +193,11 @@
|
|||
expect($result->status)->toBe(NodeRunStatus::Completed);
|
||||
expect($result->output['fetch']['count'])->toBeGreaterThan(0);
|
||||
|
||||
// ...surfaces the NEWEST item (key 'c', 15 Jun) — not the oldest — so the
|
||||
// test reflects what the user just published, and spawns no siblings.
|
||||
expect($result->output['fetched']['key'])->toBe('c');
|
||||
expect($result->output['fetch']['spawned'])->toBe(0);
|
||||
|
||||
// ...and never advances the persisted watermark.
|
||||
$state = AutomationNodeState::for($automation->id, 'fetch_1');
|
||||
expect($state->data['last_item_date'])->toBe('2030-01-01T00:00:00+00:00');
|
||||
|
|
@ -226,7 +205,7 @@
|
|||
|
||||
it('advances the production watermark on a non-manual real-data run', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
|
|
@ -256,7 +235,31 @@
|
|||
|
||||
$state = AutomationNodeState::for($automation->id, 'fetch_1');
|
||||
expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String())
|
||||
->toBe(CarbonImmutable::parse('Mon, 15 Jun 2025 12:00:00 +0000')->toIso8601String());
|
||||
->toBe(CarbonImmutable::parse('Sun, 15 Jun 2025 12:00:00 +0000')->toIso8601String());
|
||||
});
|
||||
|
||||
it('processes an Atom feed (YouTube) that the old RSS-only parser dropped', function () {
|
||||
Carbon::setTestNow('2026-06-16 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create();
|
||||
|
||||
// Watermark predates the single entry, so it counts as new.
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2026-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
|
||||
|
||||
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
|
||||
|
||||
expect($result->status)->toBe(NodeRunStatus::Completed);
|
||||
expect($result->output['fetch']['count'])->toBe(1);
|
||||
expect($result->output['fetched']['title'])->toBe('Ninguém aguenta o TikTok Shop');
|
||||
expect($result->output['fetched']['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE');
|
||||
expect($result->output['fetched']['yt_videoId'])->toBe('bIQVeW4sTcE');
|
||||
});
|
||||
|
||||
it('fails when feed_url is missing', function () {
|
||||
|
|
@ -293,14 +296,7 @@
|
|||
|
||||
it('skips items without a publish date', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
$feed = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Dated</title><link>https://1.1.1.1/d</link><guid>d</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>NoDate</title><link>https://1.1.1.1/n</link><guid>n</guid></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_dateless'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create();
|
||||
AutomationNodeState::create([
|
||||
|
|
@ -319,13 +315,7 @@
|
|||
|
||||
it('falls back to the link as the dedup key when an item has no guid', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
$feed = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>LinkKey</title><link>https://1.1.1.1/only-link</link><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]);
|
||||
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_no_guid'), 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create();
|
||||
AutomationNodeState::create([
|
||||
|
|
|
|||
|
|
@ -103,3 +103,8 @@ function createApiTestToken(array $overrides = []): array
|
|||
'user' => $user,
|
||||
];
|
||||
}
|
||||
|
||||
function feedFixture(string $name): string
|
||||
{
|
||||
return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml"));
|
||||
}
|
||||
|
|
|
|||
76
tests/Unit/Automation/FeedParserTest.php
Normal file
76
tests/Unit/Automation/FeedParserTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Automation\FeedParser;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->parser = new FeedParser;
|
||||
});
|
||||
|
||||
it('parses an Atom (YouTube) feed with aliases and raw namespaced fields', function () {
|
||||
$item = $this->parser->parse(feedFixture('youtube_atom'))[0];
|
||||
|
||||
// Aliases resolve cross-format: link comes from the Atom href attribute,
|
||||
// date from <published>, author from <author><name>.
|
||||
expect($item['title'])->toBe('Ninguém aguenta o TikTok Shop');
|
||||
expect($item['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE');
|
||||
expect($item['date'])->toBe('2026-06-15T20:24:32+00:00');
|
||||
expect($item['author'])->toBe('Beta Boechat');
|
||||
expect($item['key'])->toBe('yt:video:bIQVeW4sTcE');
|
||||
|
||||
// Raw namespaced fields stay reachable.
|
||||
expect($item['yt_videoId'])->toBe('bIQVeW4sTcE');
|
||||
expect(data_get($item, 'media_group.media_thumbnail.url'))
|
||||
->toBe('https://i3.ytimg.com/vi/bIQVeW4sTcE/hqdefault.jpg');
|
||||
expect(data_get($item, 'media_group.media_description'))
|
||||
->toBe('Descrição completa do vídeo aqui.');
|
||||
});
|
||||
|
||||
it('parses an RSS 2.0 feed with dc and content namespaces', function () {
|
||||
$item = $this->parser->parse(feedFixture('rss_namespaces'))[0];
|
||||
|
||||
expect($item['title'])->toBe('Polish is here');
|
||||
expect($item['link'])->toBe('https://feedback.changelogfy.com/en/polish-is-here');
|
||||
expect($item['author'])->toBe('Paulo Castellano');
|
||||
expect($item['key'])->toBe('16092');
|
||||
|
||||
// Extension fields the old parser threw away.
|
||||
expect($item['dc_creator'])->toBe('Paulo Castellano');
|
||||
expect($item['content_encoded'])->toBe('<p>You can now serve your changelogs fully translated in Polish.</p>');
|
||||
expect($item['categories'])->toBe(['Feature', 'i18n']);
|
||||
});
|
||||
|
||||
it('parses a podcast feed exposing the enclosure and itunes fields', function () {
|
||||
$item = $this->parser->parse(feedFixture('podcast'))[0];
|
||||
|
||||
expect(data_get($item, 'enclosure.url'))->toBe('https://cdn.changelog.com/the-changelog-682.mp3');
|
||||
expect(data_get($item, 'enclosure.type'))->toBe('audio/mpeg');
|
||||
expect($item['itunes_duration'])->toBe('1:46:28');
|
||||
expect($item['key'])->toBe('changelog.com/1/2829');
|
||||
});
|
||||
|
||||
it('preserves backward-compatible RSS 2.0 alias keys', function () {
|
||||
$item = $this->parser->parse(feedFixture('rss_namespaces'))[0];
|
||||
|
||||
// Automations saved before multi-format support reference these exact keys.
|
||||
expect($item)->toHaveKeys(['title', 'link', 'description', 'pubDate', 'key']);
|
||||
expect($item['pubDate'])->toBe($item['date']);
|
||||
});
|
||||
|
||||
it('keeps a falsy element from a tag that only carries attributes', function () {
|
||||
$item = $this->parser->parse(feedFixture('podcast'))[0];
|
||||
|
||||
// <itunes:image href="..."/> has no text — the attribute object is exposed.
|
||||
expect(data_get($item, 'itunes_image.href'))->toBe('https://cdn.changelog.com/cover.png');
|
||||
});
|
||||
|
||||
it('returns null for a body that is not a valid feed', function () {
|
||||
expect($this->parser->parse('this is not xml at all'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty list for a valid but empty feed', function () {
|
||||
$empty = '<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>';
|
||||
|
||||
expect($this->parser->parse($empty))->toBe([]);
|
||||
});
|
||||
20
tests/fixtures/feeds/podcast.xml
vendored
Normal file
20
tests/fixtures/feeds/podcast.xml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
|
||||
<channel>
|
||||
<title>The Changelog</title>
|
||||
<link>https://changelog.com/podcast</link>
|
||||
<description>Software news</description>
|
||||
<item>
|
||||
<title>From open source hits to OpenAI</title>
|
||||
<guid isPermaLink="false">changelog.com/1/2829</guid>
|
||||
<link>https://changelog.com/podcast/682</link>
|
||||
<pubDate>Fri, 05 Jun 2026 19:00:00 +0000</pubDate>
|
||||
<enclosure type="audio/mpeg" length="153465091" url="https://cdn.changelog.com/the-changelog-682.mp3"/>
|
||||
<description>This week I am talking with Max Stoiber.</description>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:image href="https://cdn.changelog.com/cover.png"/>
|
||||
<itunes:duration>1:46:28</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
5
tests/fixtures/feeds/rss_dateless.xml
vendored
Normal file
5
tests/fixtures/feeds/rss_dateless.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Dated</title><link>https://1.1.1.1/d</link><guid>d</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>NoDate</title><link>https://1.1.1.1/n</link><guid>n</guid></item>
|
||||
</channel></rss>
|
||||
6
tests/fixtures/feeds/rss_mixed.xml
vendored
Normal file
6
tests/fixtures/feeds/rss_mixed.xml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Older</title><link>https://1.1.1.1/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer1</title><link>https://1.1.1.1/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer2</title><link>https://1.1.1.1/c</link><guid>c</guid><pubDate>Sun, 15 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
20
tests/fixtures/feeds/rss_namespaces.xml
vendored
Normal file
20
tests/fixtures/feeds/rss_namespaces.xml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<title>Changelogfy</title>
|
||||
<description>Changelogfy</description>
|
||||
<link>https://feedback.changelogfy.com</link>
|
||||
<atom:link href="https://feedback.changelogfy.com/rss" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<title><![CDATA[Polish is here]]></title>
|
||||
<description><![CDATA[You can now serve your changelogs in Polish.]]></description>
|
||||
<link>https://feedback.changelogfy.com/en/polish-is-here</link>
|
||||
<guid>16092</guid>
|
||||
<dc:creator>Paulo Castellano</dc:creator>
|
||||
<pubDate>Thu, 05 Mar 2026 16:20:09 +0000</pubDate>
|
||||
<category><![CDATA[Feature]]></category>
|
||||
<category><![CDATA[i18n]]></category>
|
||||
<content:encoded><![CDATA[<p>You can now serve your changelogs fully translated in Polish.</p>]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
4
tests/fixtures/feeds/rss_no_guid.xml
vendored
Normal file
4
tests/fixtures/feeds/rss_no_guid.xml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>LinkKey</title><link>https://1.1.1.1/only-link</link><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
5
tests/fixtures/feeds/rss_old.xml
vendored
Normal file
5
tests/fixtures/feeds/rss_old.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Ancient</title><link>https://1.1.1.1/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Old</title><link>https://1.1.1.1/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
6
tests/fixtures/feeds/rss_three_new.xml
vendored
Normal file
6
tests/fixtures/feeds/rss_three_new.xml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>New1</title><link>https://1.1.1.1/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New2</title><link>https://1.1.1.1/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New3</title><link>https://1.1.1.1/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
36
tests/fixtures/feeds/youtube_atom.xml
vendored
Normal file
36
tests/fixtures/feeds/youtube_atom.xml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
|
||||
<link rel="self" href="http://www.youtube.com/feeds/videos.xml?channel_id=UC123"/>
|
||||
<id>yt:channel:123</id>
|
||||
<yt:channelId>123</yt:channelId>
|
||||
<title>Beta Boechat</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/channel/UC123"/>
|
||||
<author>
|
||||
<name>Beta Boechat</name>
|
||||
<uri>https://www.youtube.com/channel/UC123</uri>
|
||||
</author>
|
||||
<published>2015-08-11T18:07:31+00:00</published>
|
||||
<entry>
|
||||
<id>yt:video:bIQVeW4sTcE</id>
|
||||
<yt:videoId>bIQVeW4sTcE</yt:videoId>
|
||||
<yt:channelId>123</yt:channelId>
|
||||
<title>Ninguém aguenta o TikTok Shop</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=bIQVeW4sTcE"/>
|
||||
<author>
|
||||
<name>Beta Boechat</name>
|
||||
<uri>https://www.youtube.com/channel/UC123</uri>
|
||||
</author>
|
||||
<published>2026-06-15T20:24:32+00:00</published>
|
||||
<updated>2026-06-15T21:15:11+00:00</updated>
|
||||
<media:group>
|
||||
<media:title>Ninguém aguenta o TikTok Shop</media:title>
|
||||
<media:content url="https://www.youtube.com/v/bIQVeW4sTcE?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||
<media:thumbnail url="https://i3.ytimg.com/vi/bIQVeW4sTcE/hqdefault.jpg" width="480" height="360"/>
|
||||
<media:description>Descrição completa do vídeo aqui.</media:description>
|
||||
<media:community>
|
||||
<media:starRating count="3389" average="5.00" min="1" max="5"/>
|
||||
<media:statistics views="20085"/>
|
||||
</media:community>
|
||||
</media:group>
|
||||
</entry>
|
||||
</feed>
|
||||
Loading…
Reference in a new issue