mirror of
https://github.com/feeddeck/feeddeck.git
synced 2026-08-01 11:05:34 -05:00
[pinterest] Add Support for Pinterest (#74)
This commit adds a new source type "pinterest", which can be used to follow the post of an user or a board on Pinterest. To use the new source type a user can select the "Pinterest" item in the add source modal. In the form a user can provide the username or board he wants to follow via FeedDeck. In the corresponding Supabase function we then convert the input provided by the user to an valid RSS feed url for Pinterest. This means that we have to add `/feed.rss` for users and `.rss` for boards to the Pinterest url. Then we generate the source and items as for the other sources and reuse the existing components to render the preview and details item. We had to adjust the rendering logic for these items, to ignore empty values, from which also other sources will benefit.
This commit is contained in:
Binary file not shown.
@@ -12,6 +12,7 @@ import 'package:feeddeck/utils/constants.dart';
|
||||
/// - [mastodon]
|
||||
/// - [medium]
|
||||
/// - [nitter]
|
||||
/// - [pinterest]
|
||||
/// - [podcast]
|
||||
/// - [reddit]
|
||||
/// - [rss]
|
||||
@@ -30,6 +31,7 @@ enum FDSourceType {
|
||||
mastodon,
|
||||
medium,
|
||||
nitter,
|
||||
pinterest,
|
||||
podcast,
|
||||
reddit,
|
||||
rss,
|
||||
@@ -62,6 +64,8 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
return 'Medium';
|
||||
case FDSourceType.nitter:
|
||||
return 'Nitter';
|
||||
case FDSourceType.pinterest:
|
||||
return 'Pinterest';
|
||||
case FDSourceType.podcast:
|
||||
return 'Podcast';
|
||||
case FDSourceType.reddit:
|
||||
@@ -95,6 +99,8 @@ extension FDSourceTypeExtension on FDSourceType {
|
||||
return const Color(0xff000000);
|
||||
case FDSourceType.nitter:
|
||||
return const Color(0xffff6c60);
|
||||
case FDSourceType.pinterest:
|
||||
return const Color(0xffe60023);
|
||||
case FDSourceType.podcast:
|
||||
return const Color(0xff872ec4);
|
||||
case FDSourceType.reddit:
|
||||
@@ -188,6 +194,7 @@ class FDSourceOptions {
|
||||
String? mastodon;
|
||||
String? medium;
|
||||
String? nitter;
|
||||
String? pinterest;
|
||||
String? podcast;
|
||||
String? reddit;
|
||||
String? rss;
|
||||
@@ -202,6 +209,7 @@ class FDSourceOptions {
|
||||
this.mastodon,
|
||||
this.medium,
|
||||
this.nitter,
|
||||
this.pinterest,
|
||||
this.podcast,
|
||||
this.reddit,
|
||||
this.rss,
|
||||
@@ -233,6 +241,10 @@ class FDSourceOptions {
|
||||
responseData.containsKey('nitter') && responseData['nitter'] != null
|
||||
? responseData['nitter']
|
||||
: null,
|
||||
pinterest: responseData.containsKey('pinterest') &&
|
||||
responseData['pinterest'] != null
|
||||
? responseData['pinterest']
|
||||
: null,
|
||||
podcast:
|
||||
responseData.containsKey('podcast') && responseData['podcast'] != null
|
||||
? responseData['podcast']
|
||||
@@ -269,6 +281,7 @@ class FDSourceOptions {
|
||||
'mastodon': mastodon,
|
||||
'medium': medium,
|
||||
'nitter': nitter,
|
||||
'pinterest': pinterest,
|
||||
'podcast': podcast,
|
||||
'reddit': reddit,
|
||||
'rss': rss,
|
||||
|
||||
@@ -29,6 +29,8 @@ class FDIcons {
|
||||
IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData googlenews =
|
||||
IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData pinterest =
|
||||
IconData(0xe805, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData medium =
|
||||
IconData(0xe806, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData nitter =
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:feeddeck/utils/openurl.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_mastodon.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_medium.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_nitter.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_pinterest.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_podcast.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_reddit.dart';
|
||||
import 'package:feeddeck/widgets/item/details/item_details_rss.dart';
|
||||
@@ -85,6 +86,11 @@ class ItemDetails extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.pinterest:
|
||||
return ItemDetailsPinterest(
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return ItemDetailsPodcast(
|
||||
item: item,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_description.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_subtitle.dart';
|
||||
import 'package:feeddeck/widgets/item/details/utils/item_title.dart';
|
||||
|
||||
class ItemDetailsPinterest extends StatelessWidget {
|
||||
const ItemDetailsPinterest({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
ItemTitle(
|
||||
itemTitle: item.title,
|
||||
),
|
||||
ItemSubtitle(
|
||||
item: item,
|
||||
source: source,
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.markdown,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ class ItemTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (itemTitle.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return SelectableText(
|
||||
itemTitle,
|
||||
textAlign: TextAlign.left,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:feeddeck/widgets/item/preview/item_preview_googlenews.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_mastodon.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_medium.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_nitter.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_pinterest.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_podcast.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_reddit.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/item_preview_rss.dart';
|
||||
@@ -67,6 +68,11 @@ class ItemPreview extends StatelessWidget {
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.pinterest:
|
||||
return ItemPreviewPinterest(
|
||||
item: item,
|
||||
source: source,
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return ItemPreviewPodcast(
|
||||
item: item,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/models/item.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/details.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/item_actions.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/item_description.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/item_media.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/item_source.dart';
|
||||
import 'package:feeddeck/widgets/item/preview/utils/item_title.dart';
|
||||
|
||||
class ItemPreviewPinterest extends StatelessWidget {
|
||||
const ItemPreviewPinterest({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final FDItem item;
|
||||
final FDSource source;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ItemActions(
|
||||
item: item,
|
||||
onTap: () => showDetails(context, item, source),
|
||||
children: [
|
||||
ItemSource(
|
||||
sourceTitle: source.title,
|
||||
sourceSubtitle: source.type.toLocalizedString(),
|
||||
sourceType: source.type,
|
||||
sourceIcon: source.icon,
|
||||
itemPublishedAt: item.publishedAt,
|
||||
itemIsRead: item.isRead,
|
||||
),
|
||||
ItemTitle(
|
||||
itemTitle: item.title,
|
||||
),
|
||||
ItemMedia(
|
||||
itemMedia: item.media,
|
||||
),
|
||||
ItemDescription(
|
||||
itemDescription: item.description,
|
||||
sourceFormat: DescriptionFormat.html,
|
||||
tagetFormat: DescriptionFormat.plain,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,10 @@ class ItemDescription extends StatelessWidget {
|
||||
|
||||
/// [_buildPlain] renders the provided [content] as plain text.
|
||||
Widget _buildPlain(String content) {
|
||||
if (content == '') {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Constants.spacingExtraSmall,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:feeddeck/widgets/source/add/add_source_pinterest.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:feeddeck/models/column.dart';
|
||||
@@ -63,6 +64,10 @@ class _AddSourceState extends State<AddSource> {
|
||||
return AddSourceNitter(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.pinterest) {
|
||||
return AddSourcePinterst(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.podcast) {
|
||||
return AddSourcePodcast(column: widget.column);
|
||||
}
|
||||
@@ -75,6 +80,10 @@ class _AddSourceState extends State<AddSource> {
|
||||
return AddSourceRSS(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.stackoverflow) {
|
||||
return AddSourceStackOverflow(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.tumblr) {
|
||||
return AddSourceTumblr(column: widget.column);
|
||||
}
|
||||
@@ -83,10 +92,6 @@ class _AddSourceState extends State<AddSource> {
|
||||
// return AddSourceX(column: widget.column);
|
||||
// }
|
||||
|
||||
if (_sourceType == FDSourceType.stackoverflow) {
|
||||
return AddSourceStackOverflow(column: widget.column);
|
||||
}
|
||||
|
||||
if (_sourceType == FDSourceType.youtube) {
|
||||
return AddSourceYouTube(column: widget.column);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:feeddeck/models/column.dart';
|
||||
import 'package:feeddeck/models/source.dart';
|
||||
import 'package:feeddeck/repositories/app_repository.dart';
|
||||
import 'package:feeddeck/utils/api_exception.dart';
|
||||
import 'package:feeddeck/utils/constants.dart';
|
||||
import 'package:feeddeck/utils/openurl.dart';
|
||||
import 'package:feeddeck/widgets/source/add/add_source_form.dart';
|
||||
|
||||
const _helpText = '''
|
||||
The Pinterest source can be used to follow your favorite Pinterest users or
|
||||
boards.
|
||||
|
||||
- **User**: `@username` or `https://www.pinterest.com/username/`
|
||||
- **Board**: `@username/board` or `https://www.pinterest.com/username/board/`
|
||||
''';
|
||||
|
||||
/// The [AddSourcePinterest] widget is used to display the form to add a new
|
||||
/// Pinterest source.
|
||||
class AddSourcePinterst extends StatefulWidget {
|
||||
const AddSourcePinterst({
|
||||
super.key,
|
||||
required this.column,
|
||||
});
|
||||
|
||||
final FDColumn column;
|
||||
|
||||
@override
|
||||
State<AddSourcePinterst> createState() => _AddSourcePinterstState();
|
||||
}
|
||||
|
||||
class _AddSourcePinterstState extends State<AddSourcePinterst> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _pinterestController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
String _error = '';
|
||||
|
||||
/// [_addSource] adds a new Pinterst source. To add a new Pinterest source the
|
||||
/// user must provide the URL of an user / a board or an url via the
|
||||
/// [_pinterestController].
|
||||
Future<void> _addSource() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = '';
|
||||
});
|
||||
|
||||
try {
|
||||
AppRepository app = Provider.of<AppRepository>(context, listen: false);
|
||||
await app.addSource(
|
||||
widget.column.id,
|
||||
FDSourceType.pinterest,
|
||||
FDSourceOptions(
|
||||
pinterest: _pinterestController.text,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = '';
|
||||
});
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} on ApiException catch (err) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = 'Failed to add source: ${err.message}';
|
||||
});
|
||||
} catch (err) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = 'Failed to add source: ${err.toString()}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinterestController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AddSourceForm(
|
||||
onTap: _addSource,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownBody(
|
||||
selectable: true,
|
||||
data: _helpText,
|
||||
onTapLink: (text, href, title) {
|
||||
try {
|
||||
if (href != null) {
|
||||
openUrl(href);
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: Constants.spacingMiddle,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _pinterestController,
|
||||
keyboardType: TextInputType.text,
|
||||
autocorrect: false,
|
||||
enableSuggestions: true,
|
||||
maxLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Pinterest Url, Username or Board',
|
||||
),
|
||||
onFieldSubmitted: (value) => _addSource(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,13 @@ class SourceIcon extends StatelessWidget {
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.pinterest:
|
||||
return buildIcon(
|
||||
FDIcons.pinterest,
|
||||
iconSize,
|
||||
type.color,
|
||||
const Color(0xffffffff),
|
||||
);
|
||||
case FDSourceType.podcast:
|
||||
return buildIcon(
|
||||
Icons.podcasts,
|
||||
|
||||
@@ -299,6 +299,20 @@
|
||||
"search": [
|
||||
"mastodon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "84fb197462be8b1518105fbfaf61716f",
|
||||
"css": "pinterest",
|
||||
"code": 59397,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M524 0C318.5 0 115.3 137 115.3 358.8 115.3 499.9 194.7 580 242.7 580 262.6 580 274 524.7 274 509.1 274 490.5 226.5 450.8 226.5 373.3 226.5 212.2 349.1 98 507.8 98 644.3 98 745.2 175.5 745.2 318 745.2 424.4 702.6 623.9 564.3 623.9 514.4 623.9 471.7 587.8 471.7 536.2 471.7 460.4 524.6 387.1 524.6 309 524.6 176.3 336.5 200.4 336.5 360.6 336.5 394.3 340.7 431.6 355.7 462.2 328.1 581.2 271.6 758.6 271.6 881.2 271.6 919 277 956.3 280.6 994.2 287.4 1001.8 284 1001 294.4 997.2 395.4 858.9 391.8 831.9 437.5 651 462.1 697.8 525.8 723.1 576.3 723.1 789.1 723.1 884.7 515.7 884.7 328.8 884.7 129.8 712.8 0 524 0Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"pinterest"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 4096 4096" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Pinterest" transform="matrix(8.20665,0,0,8.20665,472.324,-53.3432)">
|
||||
<path d="M204,6.5C101.4,6.5 0,74.9 0,185.6C0,256 39.6,296 63.6,296C73.5,296 79.2,268.4 79.2,260.6C79.2,251.3 55.5,231.5 55.5,192.8C55.5,112.4 116.7,55.4 195.9,55.4C264,55.4 314.4,94.1 314.4,165.2C314.4,218.3 293.1,317.9 224.1,317.9C199.2,317.9 177.9,299.9 177.9,274.1C177.9,236.3 204.3,199.7 204.3,160.7C204.3,94.5 110.4,106.5 110.4,186.5C110.4,203.3 112.5,221.9 120,237.2C106.2,296.6 78,385.1 78,446.3C78,465.2 80.7,483.8 82.5,502.7C85.9,506.5 84.2,506.1 89.4,504.2C139.8,435.2 138,421.7 160.8,331.4C173.1,354.8 204.9,367.4 230.1,367.4C336.3,367.4 384,263.9 384,170.6C384,71.3 298.2,6.5 204,6.5Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -67,6 +67,7 @@ export default function Home() {
|
||||
<Mastodon />
|
||||
<Medium />
|
||||
<Nitter />
|
||||
<Pinterest />
|
||||
<Reddit />
|
||||
<RSS />
|
||||
<StackOverflow />
|
||||
@@ -252,6 +253,24 @@ const Nitter = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const Pinterest = () => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
width="32px"
|
||||
height="32px"
|
||||
viewBox="0 0 4096 4096"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<g id="Pinterest" transform="matrix(8.20665,0,0,8.20665,472.324,-53.3432)">
|
||||
<path d="M204,6.5C101.4,6.5 0,74.9 0,185.6C0,256 39.6,296 63.6,296C73.5,296 79.2,268.4 79.2,260.6C79.2,251.3 55.5,231.5 55.5,192.8C55.5,112.4 116.7,55.4 195.9,55.4C264,55.4 314.4,94.1 314.4,165.2C314.4,218.3 293.1,317.9 224.1,317.9C199.2,317.9 177.9,299.9 177.9,274.1C177.9,236.3 204.3,199.7 204.3,160.7C204.3,94.5 110.4,106.5 110.4,186.5C110.4,203.3 112.5,221.9 120,237.2C106.2,296.6 78,385.1 78,446.3C78,465.2 80.7,483.8 82.5,502.7C85.9,506.5 84.2,506.1 89.4,504.2C139.8,435.2 138,421.7 160.8,331.4C173.1,354.8 204.9,367.4 230.1,367.4C336.3,367.4 384,263.9 384,170.6C384,71.3 298.2,6.5 204,6.5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className="pt-4">Pinterest</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Reddit = () => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Redis } from 'redis';
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { getMediumFeed, isMediumUrl } from './medium.ts';
|
||||
import { getPinterestFeed, isPinterestUrl } from './pinterest.ts';
|
||||
import { getRSSFeed } from './rss.ts';
|
||||
import { getPodcastFeed } from './podcast.ts';
|
||||
import { getTumblrFeed, isTumblrUrl } from './tumblr.ts';
|
||||
@@ -49,6 +50,13 @@ export const getFeed = async (
|
||||
return await getMediumFeed(supabaseClient, redisClient, profile, source);
|
||||
case 'nitter':
|
||||
return await getNitterFeed(supabaseClient, redisClient, profile, source);
|
||||
case 'pinterest':
|
||||
return await getPinterestFeed(
|
||||
supabaseClient,
|
||||
redisClient,
|
||||
profile,
|
||||
source,
|
||||
);
|
||||
case 'podcast':
|
||||
return await getPodcastFeed(supabaseClient, redisClient, profile, source);
|
||||
case 'reddit':
|
||||
@@ -62,6 +70,13 @@ export const getFeed = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (source.options?.rss && isPinterestUrl(source.options.rss)) {
|
||||
return await getPinterestFeed(supabaseClient, redisClient, profile, {
|
||||
...source,
|
||||
options: { pinterest: source.options.rss },
|
||||
});
|
||||
}
|
||||
|
||||
if (source.options?.rss && isRedditUrl(source.options.rss)) {
|
||||
return await getTumblrFeed(supabaseClient, redisClient, profile, {
|
||||
...source,
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { parseFeed } from 'rss';
|
||||
import { Md5 } from 'std/md5';
|
||||
import { FeedEntry } from 'rss/types';
|
||||
import { Redis } from 'redis';
|
||||
import { unescape } from 'lodash';
|
||||
|
||||
import { IItem } from '../models/item.ts';
|
||||
import { ISource } from '../models/source.ts';
|
||||
import { IProfile } from '../models/profile.ts';
|
||||
import { fetchWithTimeout } from '../utils/fetchWithTimeout.ts';
|
||||
import { log } from '../utils/log.ts';
|
||||
|
||||
const pinterestUrls = [
|
||||
'pinterest.at',
|
||||
'pinterest.ca',
|
||||
'pinterest.ch',
|
||||
'pinterest.cl',
|
||||
'pinterest.co.kr',
|
||||
'pinterest.com',
|
||||
'pinterest.com.au',
|
||||
'pinterest.com.mx',
|
||||
'pinterest.co.uk',
|
||||
'pinterest.de',
|
||||
'pinterest.dk',
|
||||
'pinterest.es',
|
||||
'pinterest.fr',
|
||||
'pinterest.ie',
|
||||
'pinterest.info',
|
||||
'pinterest.it',
|
||||
'pinterest.jp',
|
||||
'pinterest.net',
|
||||
'pinterest.nz',
|
||||
'pinterest.ph',
|
||||
'pinterest.pt',
|
||||
'pinterest.ru',
|
||||
'pinterest.se',
|
||||
];
|
||||
|
||||
/**
|
||||
* `isPinterestUrl` checks if the provided `url` is a valid Pinterest url.
|
||||
*/
|
||||
export const isPinterestUrl = (url: string): boolean => {
|
||||
if (!url.startsWith('https://www.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
for (const pinterestUrl of pinterestUrls) {
|
||||
if (parsedUrl.hostname.endsWith(pinterestUrl)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getPinterestFeed = async (
|
||||
_supabaseClient: SupabaseClient,
|
||||
_redisClient: Redis | undefined,
|
||||
_profile: IProfile,
|
||||
source: ISource,
|
||||
): Promise<{ source: ISource; items: IItem[] }> => {
|
||||
/**
|
||||
* Since the `pinterest` option supports multiple input format we need to
|
||||
* normalize it to a valid Pinterest feed url. If this is not possible we
|
||||
* consider the provided option as invalid.
|
||||
*/
|
||||
if (source.options?.pinterest) {
|
||||
const input = source.options.pinterest;
|
||||
/**
|
||||
* If the input starts with `@` we assume that a username or board was
|
||||
* provided in the form of `@username` or `@username/board`. We then use
|
||||
* the provided username or board to generate a valid Pinterest feed url.
|
||||
*/
|
||||
if (input.length > 1 && input[0] === '@') {
|
||||
if (input.includes('/')) {
|
||||
source.options.pinterest = `https://www.pinterest.com/${
|
||||
input.substring(1)
|
||||
}.rss`;
|
||||
} else {
|
||||
source.options.pinterest = `https://www.pinterest.com/${
|
||||
input.substring(1)
|
||||
}/feed.rss`;
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* If the input does not start with `@` we assume that a valid Pinterest
|
||||
* url was provided and replace the domain with `pinterest.com`.
|
||||
*
|
||||
* If the url ends with `.rss` or `/feed.rss` we consider that already a
|
||||
* Pinterest RSS feed url was provided and use it as is.
|
||||
*
|
||||
* If the url does not end with `.rss` or `/feed.rss` we have to generate
|
||||
* the feed url, by appending `.rss` or `/feed.rss` to the url, depending
|
||||
* on if the url contains a `/` or not.
|
||||
*/
|
||||
if (isPinterestUrl(input)) {
|
||||
const pinterestDotComUrl = replaceDomain(input);
|
||||
if (
|
||||
pinterestDotComUrl.endsWith('.rss') ||
|
||||
pinterestDotComUrl.endsWith('/feed.rss')
|
||||
) {
|
||||
source.options.pinterest = pinterestDotComUrl;
|
||||
} else {
|
||||
const urlParameters = pinterestDotComUrl.replace(
|
||||
'https://www.pinterest.com/',
|
||||
'',
|
||||
).replace(/\/$/, '');
|
||||
if (urlParameters.includes('/')) {
|
||||
source.options.pinterest =
|
||||
`https://www.pinterest.com/${urlParameters}.rss`;
|
||||
} else {
|
||||
source.options.pinterest =
|
||||
`https://www.pinterest.com/${urlParameters}/feed.rss`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid source options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RSS for the provided `pinterest` url and parse it. If a feed
|
||||
* doesn't contains an item we return an error.
|
||||
*/
|
||||
const response = await fetchWithTimeout(source.options.pinterest, {
|
||||
method: 'get',
|
||||
}, 5000);
|
||||
const xml = await response.text();
|
||||
log('debug', 'Add source', {
|
||||
sourceType: 'pinterest',
|
||||
requestUrl: source.options.pinterest,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
const feed = await parseFeed(xml);
|
||||
|
||||
if (!feed.title.value) {
|
||||
throw new Error('Invalid feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a source id based on the user id, column id and the normalized
|
||||
* `pinterest` url. Besides that we also set the source type to `pinterest`and
|
||||
* set the title and link for the source.
|
||||
*/
|
||||
if (source.id === '') {
|
||||
source.id = generateSourceId(
|
||||
source.userId,
|
||||
source.columnId,
|
||||
source.options.pinterest,
|
||||
);
|
||||
}
|
||||
source.type = 'pinterest';
|
||||
source.title = feed.title.value;
|
||||
if (feed.links.length > 0) {
|
||||
source.link = feed.links[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Now that the source does contain all the required information we can start
|
||||
* to generate the items for the source, by looping over all the feed entries.
|
||||
*/
|
||||
const items: IItem[] = [];
|
||||
|
||||
for (const [index, entry] of feed.entries.entries()) {
|
||||
if (skipEntry(index, entry, source.updatedAt || 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each item need a unique id which is generated using the `generateItemId`
|
||||
* function. The id is a combination of the source id and the id of the
|
||||
* entry or if the entry does not have an id we use the link of the first
|
||||
* link of the entry.
|
||||
*/
|
||||
let itemId = '';
|
||||
if (entry.id != '') {
|
||||
itemId = generateItemId(source.id, entry.id);
|
||||
} else if (entry.links.length > 0 && entry.links[0].href) {
|
||||
itemId = generateItemId(source.id, entry.links[0].href);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the item object and add it to the `items` array.
|
||||
*/
|
||||
items.push({
|
||||
id: itemId,
|
||||
userId: source.userId,
|
||||
columnId: source.columnId,
|
||||
sourceId: source.id,
|
||||
title: entry.title?.value ?? '',
|
||||
link: entry.links[0].href!,
|
||||
media: getMedia(entry),
|
||||
description: getItemDescription(entry),
|
||||
author: `@${
|
||||
source.options.pinterest.replace('https://www.pinterest.com/', '')
|
||||
.replace('.rss', '').replace('/feed.rss', '').split('/')[0]
|
||||
}`,
|
||||
publishedAt: Math.floor(entry.published!.getTime() / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
return { source, items };
|
||||
};
|
||||
|
||||
/**
|
||||
* `replaceDomain` replaces the domain of the Pinterest url with
|
||||
* `pinterest.com`.
|
||||
*/
|
||||
const replaceDomain = (url: string): string => {
|
||||
let finalUrl = url;
|
||||
|
||||
for (const pinterestUrl of pinterestUrls) {
|
||||
if (pinterestUrl !== 'pinterest.com') {
|
||||
finalUrl = finalUrl.replace(pinterestUrl, 'pinterest.com');
|
||||
}
|
||||
}
|
||||
return finalUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* `skipEntry` is used to determin if an entry should be skipped or not. When a
|
||||
* entry in the RSS feed is skipped it will not be added to the database. An
|
||||
* entry will be skipped when
|
||||
* - it is not within the first 50 entries of the feed, because we only keep the
|
||||
* last 50 items of each source in our
|
||||
* delete logic.
|
||||
* - the entry does not contain a title, a link or a published date.
|
||||
* - the published date of the entry is older than the last update date of the
|
||||
* source minus 10 seconds.
|
||||
*/
|
||||
const skipEntry = (
|
||||
index: number,
|
||||
entry: FeedEntry,
|
||||
sourceUpdatedAt: number,
|
||||
): boolean => {
|
||||
if (index === 50) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((entry.links.length === 0 || !entry.links[0].href) || !entry.published) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.floor(entry.published.getTime() / 1000) <= (sourceUpdatedAt - 10)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* `generateSourceId` generates a unique source id based on the user id, column
|
||||
* id and the link of the RSS feed. We use the MD5 algorithm for the link to
|
||||
* generate the id.
|
||||
*/
|
||||
const generateSourceId = (
|
||||
userId: string,
|
||||
columnId: string,
|
||||
link: string,
|
||||
): string => {
|
||||
return `pinterest-${userId}-${columnId}-${new Md5().update(link).toString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `generateItemId` generates a unique item id based on the source id and the
|
||||
* identifier of the item. We use the MD5 algorithm for the identifier, which
|
||||
* can be the link of the item or the id of the item.
|
||||
*/
|
||||
const generateItemId = (sourceId: string, identifier: string): string => {
|
||||
return `${sourceId}-${new Md5().update(identifier).toString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getItemDescription` returns the description of the item. If the item has a
|
||||
* `content` property we use that as our description, otherwise we use the
|
||||
* `description` property.
|
||||
*/
|
||||
const getItemDescription = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.description?.value) {
|
||||
return unescape(entry.description.value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMedia` returns an image for the provided feed entry from it's content or
|
||||
* description. If we could not get an image from the content or description we
|
||||
* return `undefined`.
|
||||
*/
|
||||
const getMedia = (entry: FeedEntry): string | undefined => {
|
||||
if (entry.description?.value) {
|
||||
const matches = /<img[^>]+\bsrc=["']([^"']+)["']/.exec(
|
||||
unescape(entry.description.value),
|
||||
);
|
||||
if (matches && matches.length == 2 && matches[1].startsWith('https://')) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -203,7 +203,6 @@ const getFeedFromWebsite = async (
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const rssLink = $('link[type="application/rss+xml"]').attr('href');
|
||||
console.log(rssLink);
|
||||
if (!rssLink) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TSourceType =
|
||||
| 'mastodon'
|
||||
| 'medium'
|
||||
| 'nitter'
|
||||
| 'pinterest'
|
||||
| 'podcast'
|
||||
| 'reddit'
|
||||
| 'rss'
|
||||
@@ -30,16 +31,17 @@ export interface ISource {
|
||||
}
|
||||
|
||||
export interface ISourceOptions {
|
||||
rss?: string;
|
||||
youtube?: string;
|
||||
github?: ISourceOptionsGithub;
|
||||
googlenews?: ISourceOptionsGoogleNews;
|
||||
mastodon?: string;
|
||||
medium?: string;
|
||||
nitter?: string;
|
||||
reddit?: string;
|
||||
pinterest?: string;
|
||||
podcast?: string;
|
||||
github?: ISourceOptionsGithub;
|
||||
googlenews?: ISourceOptionsGoogleNews;
|
||||
reddit?: string;
|
||||
rss?: string;
|
||||
stackoverflow?: ISourceOptionsStackOverflow;
|
||||
tumblr?: string;
|
||||
x?: string;
|
||||
stackoverflow?: ISourceOptionsStackOverflow;
|
||||
youtube?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user