Repository: scrapy/scrapy

Issue:
Feeds Enhancement: Item Filters
<!--

Thanks for taking an interest in Scrapy!

If you have a question that starts with "How to...", please see the Scrapy Community page: https://scrapy.org/community/.
The GitHub issue tracker's purpose is to deal with bug reports and feature requests for the project itself.

Keep in mind that by filing an issue, you are expected to comply with Scrapy's Code of Conduct, including treating everyone with respect: https://github.com/scrapy/scrapy/blob/master/CODE_OF_CONDUCT.md

The following is a suggested template to structure your pull request, you can find more guidelines at https://doc.scrapy.org/en/latest/contributing.html#writing-patches and https://doc.scrapy.org/en/latest/contributing.html#submitting-patches

-->

## Summary

Currently there are no convenient ways to filter items before they can be exported. An ```ItemChecker``` class can be used to filter items while also providing flexibility to the user. 

## Motivation/Proposal

Scrapy currently doesn't have any convenient APIs to customize conditions for item exports. An ```ItemChecker``` class can be used by the user to define constraints for acceptable items for particular feeds.

The ```ItemChecker``` class can have 3 main public methods ```accepts```, ```accepts_class``` and ```accepts_fields```. Scrapy will mainly use ```accepts``` method to decide if an item is acceptable, ```accepts_class``` and ```accepts_fields``` will have certain default behaviors which can be overriden by the user should they want to customize them.
```python
class ItemChecker:
   """
   This will be used by FeedExporter to decide if an item should be allowed
   to be exported to a particular feed.
   :param feed_options: FEEDS dictionary passed from FeedExporter
   :type feed_options: dict
   """
   accepted_items = []    # list of Items user wants to accept

    def __init__(self, feed_options):
        # populate accepted_items with item_classes values from feed_options if present

    def accepts(self, item):
        """
        Main method to be used by FeedExporter to check if the item is acceptable according
        to defined constraints. This method uses accepts_class and accept_fields method
        to decide if the item is acceptable.
        :param item: scraped item which user wants to check if is acceptable
        :type item: scrapy supported items (dictionaries, Item objects, dataclass objects, and attrs objects)
        :return: `True` if accepted, `False` otherwise
        :rtype: bool
        """

    def accepts_class(self, item):
        """
        Method to check if the item is an instance of a class declared in accepted_items
        list. Can be overriden by user if they want allow certain item classes.
        Default behaviour: if accepted_items is empty then all items will be
        accepted else only items present in accepted_items will be accepted.
        :param item: scraped item
        :type item: scrapy supported items  (dictionaries, Item objects, dataclass objects, and attrs objects)
        :return: `True` if item in accepted_items, `False` otherwise
        :rtype: bool
        """

    def accepts_fields(self, fields):
        """
        Method to check if certain fields of the item passes the filtering
        criteria. Users can override this method to add their own custom
        filters.
        Default behaviour: accepts all fields.
        :param fields: all the fields of the scraped item
        :type fields: dict
        :return: `True` if all the fields passes the filtering criteria, else `False`
        :rtype: bool
        """
```
Such custom filters can be declared in ```settings.py```. For convenience Items can also be declared here without needing to create a custom ```ItemChecker``` class.
```python
from myproject.filterfile import MyFilter1
from myproject.items import MyItem1, MyItem2

FEEDS = {
    'items1.json': {
        'format': 'json',
        'item_filter': MyFilter1,
    },
    'items2.xml': {
        'format': 'xml',
        'item_classes': (MyItem1, MyItem2),
    },
}
```


## Describe alternatives you've considered

This feature builds upon #4576. 

## Additional context

This feature proposal is part of a GSoC project (see #4963). This issue has been created to get inputs from the Scrapy community to refine the proposed feature.

Add 'item_classes' key to FEEDS
Fixes #4575 

I think it would be nice to be able to filter which items get exported to each URI in FEEDS.

This update allows for an 'item_classes' key (optional) to be added to FEEDS. If 'item_classes' is not provided, it defaults to `None` which includes all item classes. This behavior is meant to mimic the behavior of the 'fields' key.

```
from my_project.items import ProductItem, AnotherItem

FEEDS = {
        'product_items.json': {
            'format': 'json',
            'item_classes': ProductItem                      <-- scrapy.Item object
        },
        'other_items.json': {
            'format': 'json',
            'item_classes': (ProductItem, AnotherItem)       <-- set of scrapy.Item objects
        },
        'all_items.json': {
            'format': 'json'
        }
    }
```


Discussion:
> Scrapy currently exports all the items it scrapes. Users may want to discard some items from exporting based on the scraped item's characteristics.

Note that, the way you describe it here, this is already possible using [item pipelines](https://docs.scrapy.org/en/latest/topics/item-pipeline.html). The point of item filtering in feed exports is to be able to send different items to different output files.

> The ```ItemChecker``` class can have 3 main public methods ```accepts```, ```accepts_item``` and ```accepts_fields```.

As long as we are discussing the API, and not the specific implementation of the default item-filtering class, and following the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle), I think a single method that gets the item should be enough.

There’s also my last point about item-filtering in https://github.com/scrapy/scrapy/issues/4963#issuecomment-817878199. Item-filtering classes should have access to feed options, so that users can write item-filtering classes that behave differently depending on custom feed options. So maybe that method should also get a `feed_options` parameter.

Edit: I’ve just though that getting the feed path, i.e. the key of the `FEEDS` dictionary, may be useful as well.

> `'accepted_items': (MyItem1, MyItem2),`

I think the naming in #4576 for this option is more descriptive, `item_classes`.
> Note that, the way you describe it here, this is already possible using item pipelines. The point of item filtering in feed exports is to be able to send different items to different output files.

Ok yeah that's a little misleading. I'll correct it.

> As long as we are discussing the API, and not the specific implementation of the default item-filtering class, and following the KISS principle, I think a single method that gets the item should be enough.

I thought making the user override ```accept_items``` and ```accepts_fields``` methods instead of touching ```accepts``` method may make it easier for them to debug and test. But yeah having just the ```accepts``` method would be more simple.

> There’s also my last point about item-filtering in #4963 (comment). Item-filtering classes should have access to feed options, so that users can write item-filtering classes that behave differently depending on custom feed options. So maybe that method should also get a feed_options parameter.
I’ve just though that getting the feed path, i.e. the key of the FEEDS dictionary, may be useful as well.

I was planning on passing whole FEEDS dictionary. ```ItemChecker``` will need it to access ```item_classes``` values declared in ```settings.py```.

> I think the naming in #4576 for this option is more descriptive, item_classes.

Okay :+1: 
> I thought making the user override accept_items and accepts_fields methods instead of touching accepts method may make it easier for them to debug and test.

It can be. So it probably makes sense to have those methods in the default class. That way, if they decide to subclass it and use their subclass for filtering, they can do as you say.

However, for the API that is expected of the class, we should keep things simple.

When I say API, I mean the methods that Scrapy will call on the component. And if I understand your proposal correctly, your plan is for Scrapy to call ItemFilter.accepts, and ItemFilter.accepts to call then the other 2 methods. So Scrapy would never call those other 2 methods, and hence we don’t need every item filtering class to implement them.

> I was planning on passing whole FEEDS dictionary.

The point of item filter is to allow filtering items differently for different feeds. So `accepts` needs to know for which feed it is filtering in some way. If the signature is `accepts(item, FEEDS)`, the method will return the same value for all feeds.
> The point of item filter is to allow filtering items differently for different feeds. So accepts needs to know for which feed it is filtering in some way. If the signature is accepts(item, FEEDS), the method will return the same value for all feeds.

I think there's some misunderstanding on how ```ItemChecker``` will filter item for a particular feed because I only included the APIs in the issue. I intend the feed slot to have the ```ItemChecker``` as an attribute so it can check if an item is acceptable with a call ```slot.item_checker.accepts(item)``` in ```item_scraped``` method of ```FeedExporter```. But yeah I agree for the ```ItemChecker``` to have info on the feed slot, it should be passed only those particular info for that feed path. So passing only the feed path and its value from the FEEDS dictionary should do it?

> However, for the API that is expected of the class, we should keep things simple.

Ok understood I was a little confused about the which APIs we should be discussing here so I wasn't sure what to include here. :sweat_smile: 
OK, so you are saying that there is going to be 1 instance of the indicated item-filtering class per feed. Makes sense. I also see you have updated the API to expect `feed_options` in `__init__` accordingly.

My only remaining feedback is that `accepts_item` sounds a bit ambiguous: I would expect `ItemChecker.accepts` and `ItemChecker.accepts_item`, both of which get an item as parameter, to do the same. What about something more in line with `accepts_fields`, like `accepts_class` or `accepts_type`?
> My only remaining feedback is that accepts_item sounds a bit ambiguous: I would expect ItemChecker.accepts and ItemChecker.accepts_item, both of which get an item as parameter, to do the same. What about something more in line with accepts_fields, like accepts_class or accepts_type?

I think ```accepts_class``` will do it. I'll update accordingly.
@elacuesta Thanks for taking the time to review my code!
I think you're right about naming the key, `item_classes` thanks for the suggestion! I'll update with your other suggestions as well.

Thanks again!
Many thanks for the updates. Could you take a look at the Travis failures?
@elacuesta I've spent quite a bit of time looking into the travis errors and am not sure what's going on, unfortunately. The tests pass for me locally but seem to fail unpredictably in travis. Might you be able to take a quick look and just see if anything stands out? Basically, the order of fields seem to get saved in different orders depending on the test/environment?

Attached is an image showing that if I correct for the travis test, it fails locally, and vice versa.
![Screen Shot 2020-05-14 at 6 26 37 PM](https://user-images.githubusercontent.com/48297632/82005141-79cc4380-9619-11ea-9ac6-7a75248b4312.png)

Yes, that’s because we still support Python 3.5, where the key order of dictionaries is not kept.
You could change the tests to check that the values are both `bytes`, then load them with `json.loads`, and _then_ check that they match.
Indeed. Another thing you could do would be to use items with only one field, as the `test_export_multiple_configs` and `test_export_indentation` methods do, for instance.
# [Codecov](https://codecov.io/gh/scrapy/scrapy/pull/4576?src=pr&el=h1) Report
> Merging [#4576](https://codecov.io/gh/scrapy/scrapy/pull/4576?src=pr&el=desc) into [master](https://codecov.io/gh/scrapy/scrapy/commit/3d58e5e3be9e266b9b0f89b094823b76511a21b1&el=desc) will **increase** coverage by `0.19%`.
> The diff coverage is `100.00%`.


```diff
@@            Coverage Diff             @@
##           master    #4576      +/-   ##
==========================================
+ Coverage   84.55%   84.75%   +0.19%     
==========================================
  Files         164      163       -1     
  Lines        9923     9974      +51     
  Branches     1475     1487      +12     
==========================================
+ Hits         8390     8453      +63     
+ Misses       1266     1254      -12     
  Partials      267      267              
```

| [Impacted Files](https://codecov.io/gh/scrapy/scrapy/pull/4576?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [scrapy/exporters.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L2V4cG9ydGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [scrapy/extensions/feedexport.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L2V4dGVuc2lvbnMvZmVlZGV4cG9ydC5weQ==) | `84.68% <100.00%> (+0.20%)` | :arrow_up: |
| [scrapy/utils/conf.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L3V0aWxzL2NvbmYucHk=) | `93.13% <100.00%> (+0.06%)` | :arrow_up: |
| [scrapy/\_\_init\_\_.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L19faW5pdF9fLnB5) | `84.21% <0.00%> (-1.51%)` | :arrow_down: |
| [scrapy/downloadermiddlewares/cookies.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L2Rvd25sb2FkZXJtaWRkbGV3YXJlcy9jb29raWVzLnB5) | `95.74% <0.00%> (-1.14%)` | :arrow_down: |
| [scrapy/spiders/crawl.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L3NwaWRlcnMvY3Jhd2wucHk=) | `92.39% <0.00%> (-0.09%)` | :arrow_down: |
| [scrapy/shell.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L3NoZWxsLnB5) | `68.21% <0.00%> (ø)` | |
| [scrapy/cmdline.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L2NtZGxpbmUucHk=) | `67.74% <0.00%> (ø)` | |
| [scrapy/robotstxt.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L3JvYm90c3R4dC5weQ==) | `97.53% <0.00%> (ø)` | |
| [scrapy/selector/\_\_init\_\_.py](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree#diff-c2NyYXB5L3NlbGVjdG9yL19faW5pdF9fLnB5) | `100.00% <0.00%> (ø)` | |
| ... and [10 more](https://codecov.io/gh/scrapy/scrapy/pull/4576/diff?src=pr&el=tree-more) | |

To avoid having to add those 2 lines to every exporter, I think we should consider applying the option through `_FeedSlot` instead. See https://github.com/scrapy/scrapy/pull/4434.

There is a `slot.exporter.export_item` line in `feedexport.py`. You could implement a method of `_FeedSlot` that starts with those two lines that check the item class, and then call `slot.exporter.export_item`, and you could replace the current call to `slot.exporter.export_item` with a call to that new `slot.export_item` method.
@longkyle Seeing the message for f97b521c4035f9db2393ba7393b081e3e3ed73d2, I'd like to mention that you can trigger a new build by closing and re-opening the PR. Additionally, I'd recommend you to run the tests locally to spot any failures before pushing, it's usually much more comfortable and fast than waiting for the Travis build. See [this section](https://docs.scrapy.org/en/latest/contributing.html#tests) for some guidelines on how to run tests.
For the record, the last build error is unrelated to this PR :+1: 
@elacuesta Thanks for the tips! I had to rerun one of them because travis-ci said that everything passed but still said "pending" on github. It hung there for a few hours. If that last build error is unrelated then we should be good to go. (although I pushed another commit right before I read your tips *face palm*)

Anyway, thanks!
Implementation is looking great, but I think we need to cover this more extensively in the docs. It's a useful feature and I think it's a bit hidden, only mentioned once in the allowed keys for the `FEEDS` setting and without any context. Given that it has no fallback setting, I think a small paragraph after the `FEEDS` docs could be enough, with a `versionadded` directive (possibly 2.2 if we include it in the next release, but we can always update later if that's not the case).
Hello! Just saw this and I like the idea, but I am now thinking maybe it makes more sense to have a filter behavior in the FeedExporter rather than in ItemExporter? It just sounds a bit off to me to make ItemExporter to decide whether an item should be exported or not.
Sounds good to me.

I don’t think we need to implement support for complex filters in this pull request, though, unless @longkyle wishes to do it. I think here we could simply replace `item_classes` with `filter`, and make the necessary changes to move the logic out of the item exporters into the feed exporter.

That would leave room for implementing support for a more complex filter in a later pull request, where we can extend this `filter` to, in addition of Item subclasses and sequences of them, allow for a filtering component (e.g. the import path of a class supporting `from_crawler` and `from_settings` and implementing a filtering method).
I think that's a great feature, with many potential use cases. It plays nicely with attrs and dataclass items as well.
Works great 👍  however the FEED_STORE_EMPTY settings has no more effect. Feeds without any items in them still have their file created.
@longkyle do you intend to keep the work here?
I'd add that we could rename `item_classes` to `match` and then it can be: a class, a tuple of classes, a matching function for any complex behavior from the user perspective.
Also if the previous comment about `FEED_STORE_EMPTY` holds, this is a regression that we need to fix and leave it explicit in the docs as it can break some stuff
@ejulio I was initially excited about contributing this feature to the community. It was approved and then just sat here without getting folded into the new releases. Unfortunately, I don't have the time to pick this back up at this point. If anybody else would like to pickup the torch, I'd be happy to see this feature get implemented!

Resolving patch:
diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py
--- a/scrapy/extensions/feedexport.py
+++ b/scrapy/extensions/feedexport.py
@@ -46,6 +46,38 @@ def build_storage(builder, uri, *args, feed_options=None, preargs=(), **kwargs):
     return builder(*preargs, uri, *args, **kwargs)
 
 
+class ItemFilter:
+    """
+    This will be used by FeedExporter to decide if an item should be allowed
+    to be exported to a particular feed.
+
+    :param feed_options: feed specific options passed from FeedExporter
+    :type feed_options: dict
+    """
+
+    def __init__(self, feed_options):
+        self.feed_options = feed_options
+        self.item_classes = set()
+
+        if 'item_classes' in self.feed_options:
+            for item_class in self.feed_options['item_classes']:
+                self.item_classes.add(load_object(item_class))
+
+    def accepts(self, item):
+        """
+        Return ``True`` if `item` should be exported or ``False`` otherwise.
+
+        :param item: scraped item which user wants to check if is acceptable
+        :type item: :ref:`Scrapy items <topics-items>`
+        :return: `True` if accepted, `False` otherwise
+        :rtype: bool
+        """
+        if self.item_classes:
+            return isinstance(item, tuple(self.item_classes))
+
+        return True    # accept all items if none declared in item_classes
+
+
 class IFeedStorage(Interface):
     """Interface that all Feed Storages must implement"""
 
@@ -215,7 +247,7 @@ def _store_in_thread(self, file):
 
 
 class _FeedSlot:
-    def __init__(self, file, exporter, storage, uri, format, store_empty, batch_id, uri_template):
+    def __init__(self, file, exporter, storage, uri, format, store_empty, batch_id, uri_template, filter):
         self.file = file
         self.exporter = exporter
         self.storage = storage
@@ -225,6 +257,7 @@ def __init__(self, file, exporter, storage, uri, format, store_empty, batch_id,
         self.store_empty = store_empty
         self.uri_template = uri_template
         self.uri = uri
+        self.filter = filter
         # flags
         self.itemcount = 0
         self._exporting = False
@@ -255,6 +288,7 @@ def __init__(self, crawler):
         self.settings = crawler.settings
         self.feeds = {}
         self.slots = []
+        self.filters = {}
 
         if not self.settings['FEEDS'] and not self.settings['FEED_URI']:
             raise NotConfigured
@@ -269,12 +303,14 @@ def __init__(self, crawler):
             uri = str(self.settings['FEED_URI'])  # handle pathlib.Path objects
             feed_options = {'format': self.settings.get('FEED_FORMAT', 'jsonlines')}
             self.feeds[uri] = feed_complete_default_values_from_settings(feed_options, self.settings)
+            self.filters[uri] = self._load_filter(feed_options)
         # End: Backward compatibility for FEED_URI and FEED_FORMAT settings
 
         # 'FEEDS' setting takes precedence over 'FEED_URI'
         for uri, feed_options in self.settings.getdict('FEEDS').items():
             uri = str(uri)  # handle pathlib.Path objects
             self.feeds[uri] = feed_complete_default_values_from_settings(feed_options, self.settings)
+            self.filters[uri] = self._load_filter(feed_options)
 
         self.storages = self._load_components('FEED_STORAGES')
         self.exporters = self._load_components('FEED_EXPORTERS')
@@ -368,6 +404,7 @@ def _start_new_batch(self, batch_id, uri, feed_options, spider, uri_template):
             store_empty=feed_options['store_empty'],
             batch_id=batch_id,
             uri_template=uri_template,
+            filter=self.filters[uri_template]
         )
         if slot.store_empty:
             slot.start_exporting()
@@ -376,6 +413,10 @@ def _start_new_batch(self, batch_id, uri, feed_options, spider, uri_template):
     def item_scraped(self, item, spider):
         slots = []
         for slot in self.slots:
+            if not slot.filter.accepts(item):
+                slots.append(slot)    # if slot doesn't accept item, continue with next slot
+                continue
+
             slot.start_exporting()
             slot.exporter.export_item(item)
             slot.itemcount += 1
@@ -486,3 +527,8 @@ def _get_uri_params(self, spider, uri_params, slot=None):
         uripar_function = load_object(uri_params) if uri_params else lambda x, y: None
         uripar_function(params, spider)
         return params
+
+    def _load_filter(self, feed_options):
+        # load the item filter if declared else load the default filter class
+        item_filter_class = load_object(feed_options.get("item_filter", ItemFilter))
+        return item_filter_class(feed_options)


Test patch:
diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py
--- a/tests/test_feedexport.py
+++ b/tests/test_feedexport.py
@@ -561,6 +561,10 @@ class MyItem(scrapy.Item):
         egg = scrapy.Field()
         baz = scrapy.Field()
 
+    class MyItem2(scrapy.Item):
+        foo = scrapy.Field()
+        hello = scrapy.Field()
+
     def _random_temp_filename(self, inter_dir=''):
         chars = [random.choice(ascii_letters + digits) for _ in range(15)]
         filename = ''.join(chars)
@@ -888,13 +892,9 @@ def test_export_no_items_multiple_feeds(self):
     @defer.inlineCallbacks
     def test_export_multiple_item_classes(self):
 
-        class MyItem2(scrapy.Item):
-            foo = scrapy.Field()
-            hello = scrapy.Field()
-
         items = [
             self.MyItem({'foo': 'bar1', 'egg': 'spam1'}),
-            MyItem2({'hello': 'world2', 'foo': 'bar2'}),
+            self.MyItem2({'hello': 'world2', 'foo': 'bar2'}),
             self.MyItem({'foo': 'bar3', 'egg': 'spam3', 'baz': 'quux3'}),
             {'hello': 'world4', 'egg': 'spam4'},
         ]
@@ -929,6 +929,114 @@ class MyItem2(scrapy.Item):
         yield self.assertExported(items, header, rows,
                                   settings=settings, ordered=True)
 
+    @defer.inlineCallbacks
+    def test_export_based_on_item_classes(self):
+        items = [
+            self.MyItem({'foo': 'bar1', 'egg': 'spam1'}),
+            self.MyItem2({'hello': 'world2', 'foo': 'bar2'}),
+            {'hello': 'world3', 'egg': 'spam3'},
+        ]
+
+        formats = {
+            'csv': b'baz,egg,foo\r\n,spam1,bar1\r\n',
+            'json': b'[\n{"hello": "world2", "foo": "bar2"}\n]',
+            'jsonlines': (
+                b'{"foo": "bar1", "egg": "spam1"}\n'
+                b'{"hello": "world2", "foo": "bar2"}\n'
+            ),
+            'xml': (
+                b'<?xml version="1.0" encoding="utf-8"?>\n<items>\n<item>'
+                b'<foo>bar1</foo><egg>spam1</egg></item>\n<item><hello>'
+                b'world2</hello><foo>bar2</foo></item>\n<item><hello>world3'
+                b'</hello><egg>spam3</egg></item>\n</items>'
+            ),
+        }
+
+        settings = {
+            'FEEDS': {
+                self._random_temp_filename(): {
+                    'format': 'csv',
+                    'item_classes': [self.MyItem],
+                },
+                self._random_temp_filename(): {
+                    'format': 'json',
+                    'item_classes': [self.MyItem2],
+                },
+                self._random_temp_filename(): {
+                    'format': 'jsonlines',
+                    'item_classes': [self.MyItem, self.MyItem2],
+                },
+                self._random_temp_filename(): {
+                    'format': 'xml',
+                },
+            },
+        }
+
+        data = yield self.exported_data(items, settings)
+        for fmt, expected in formats.items():
+            self.assertEqual(expected, data[fmt])
+
+    @defer.inlineCallbacks
+    def test_export_based_on_custom_filters(self):
+        items = [
+            self.MyItem({'foo': 'bar1', 'egg': 'spam1'}),
+            self.MyItem2({'hello': 'world2', 'foo': 'bar2'}),
+            {'hello': 'world3', 'egg': 'spam3'},
+        ]
+
+        MyItem = self.MyItem
+
+        class CustomFilter1:
+            def __init__(self, feed_options):
+                pass
+
+            def accepts(self, item):
+                return isinstance(item, MyItem)
+
+        class CustomFilter2(scrapy.extensions.feedexport.ItemFilter):
+            def accepts(self, item):
+                if 'foo' not in item.fields:
+                    return False
+                return True
+
+        class CustomFilter3(scrapy.extensions.feedexport.ItemFilter):
+            def accepts(self, item):
+                if isinstance(item, tuple(self.item_classes)) and item['foo'] == "bar1":
+                    return True
+                return False
+
+        formats = {
+            'json': b'[\n{"foo": "bar1", "egg": "spam1"}\n]',
+            'xml': (
+                b'<?xml version="1.0" encoding="utf-8"?>\n<items>\n<item>'
+                b'<foo>bar1</foo><egg>spam1</egg></item>\n<item><hello>'
+                b'world2</hello><foo>bar2</foo></item>\n</items>'
+            ),
+            'jsonlines': b'{"foo": "bar1", "egg": "spam1"}\n',
+        }
+
+        settings = {
+            'FEEDS': {
+                self._random_temp_filename(): {
+                    'format': 'json',
+                    'item_filter': CustomFilter1,
+                },
+                self._random_temp_filename(): {
+                    'format': 'xml',
+                    'item_filter': CustomFilter2,
+                },
+                self._random_temp_filename(): {
+                    'format': 'jsonlines',
+                    'item_classes': [self.MyItem, self.MyItem2],
+                    'item_filter': CustomFilter3,
+                },
+            },
+        }
+
+        data = yield self.exported_data(items, settings)
+        for fmt, expected in formats.items():
+            self.assertEqual(expected, data[fmt])
+
     @defer.inlineCallbacks
     def test_export_dicts(self):
         # When dicts are used, only keys from the first row are used as