[fansly] add initial support (#4401)

- only posts and creator posts work
- 'home' and 'list' extractors do nothing atm
- needs 'authorization' header as 'token' config value
  to access locked content
- needs yt-dlp to download 1080p videos
This commit is contained in:
Mike Fährmann
2025-09-02 22:03:37 +02:00
parent 1c9f4ff867
commit e2ca64a636
6 changed files with 264 additions and 0 deletions

View File

@@ -2811,6 +2811,18 @@ Description
`fanbox.comments <extractor.fanbox.comments_>`__
extractor.fansly.token
----------------------
Type
``string``
Example
``"kX7pL9qW3zT2rY8mB5nJ4vC6xF1tA0hD8uE2wG9yR3sQ7iZ4oM5jN6cP8lV0bK2tU9aL1eW"``
Description
``authorization`` header value
used for requests to ``https://apiv3.fansly.com/api``
to access locked content.
extractor.flickr.access-token & .access-token-secret
----------------------------------------------------
Type

View File

@@ -328,6 +328,10 @@
"fee-max" : null,
"metadata": false
},
"fansly":
{
"token": ""
},
"flickr":
{
"access-token" : null,

View File

@@ -277,6 +277,12 @@ Consider all listed sites to potentially be NSFW.
<td>Albums, Avatars, User Profile Information, Photos, Profile Photos, Sets, User Profiles, Videos</td>
<td><a href="https://github.com/mikf/gallery-dl#cookies">Cookies</a></td>
</tr>
<tr id="fansly" title="fansly">
<td>Fansly</td>
<td>https://fansly.com/</td>
<td>Creator-posts, Home Feed, Lists, Posts</td>
<td></td>
</tr>
<tr id="fantia" title="fantia">
<td>Fantia</td>
<td>https://fantia.jp/</td>

View File

@@ -56,6 +56,7 @@ modules = [
"exhentai",
"facebook",
"fanbox",
"fansly",
"fantia",
"fapello",
"fapachi",

View File

@@ -0,0 +1,213 @@
# -*- coding: utf-8 -*-
# Copyright 2025 Mike Fährmann
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
"""Extractors for https://fansly.com/"""
from .common import Extractor, Message
from .. import text
import time
BASE_PATTERN = r"(?:https?://)?(?:www\.)?fansly\.com"
class FanslyExtractor(Extractor):
"""Base class for fansly extractors"""
category = "fansly"
root = "https://fansly.com"
directory_fmt = ("{category}", "{account[username]} ({account[id]})")
filename_fmt = "{id}_{num}_{file[id]}.{extension}"
archive_fmt = "{file[id]}"
def _init(self):
self.api = FanslyAPI(self)
def items(self):
for post in self.posts():
files = self._extract_files(post)
post["count"] = len(files)
post["date"] = text.parse_timestamp(post["createdAt"])
yield Message.Directory, post
for post["num"], file in enumerate(files, 1):
post.update(file)
url = file["url"]
yield Message.Url, url, text.nameext_from_url(url, post)
def _extract_files(self, post):
files = []
for attachment in post.pop("attachments"):
media = attachment["media"]
file = {
**media,
"date": text.parse_timestamp(media["createdAt"]),
"date_updated": text.parse_timestamp(media["updatedAt"]),
}
width = 0
for variant in media["variants"]:
if variant["width"] > width:
width = variant["width"]
variant_max = variant
if variant["type"] == 303:
break
else:
# image
file["type"] = "image"
files.append({
"file": file,
"url" : variant_max["locations"][0]["location"],
})
continue
# video
location = variant["locations"][0]
meta = location["metadata"]
file["type"] = "video"
files.append({
"file": file,
"url": f"ytdl:{location['location']}",
"_fallback": (media["locations"][0]["location"],),
"_ytdl_manifest": "dash",
"_ytdl_manifest_cookies": (
("CloudFront-Key-Pair-Id", meta["Key-Pair-Id"]),
("CloudFront-Signature" , meta["Signature"]),
("CloudFront-Policy" , meta["Policy"]),
),
})
return files
class FanslyPostExtractor(FanslyExtractor):
subcategory = "post"
pattern = rf"{BASE_PATTERN}/post/(\d+)"
example = "https://fansly.com/post/1234567890"
def posts(self):
return self.api.post(self.groups[0])
class FanslyHomeExtractor(FanslyExtractor):
subcategory = "home"
pattern = rf"{BASE_PATTERN}/home(?:/(subscribed|list/(\d+)))?"
example = "https://fansly.com/home"
def items(self):
pass
class FanslyListExtractor(FanslyExtractor):
subcategory = "list"
pattern = rf"{BASE_PATTERN}/lists/(\d+)"
example = "https://fansly.com/lists/1234567890"
def items(self):
pass
class FanslyCreatorPostsExtractor(FanslyExtractor):
subcategory = "creator-posts"
pattern = rf"{BASE_PATTERN}/([^/?#]+)/posts"
example = "https://fansly.com/CREATOR/posts"
def posts(self):
account = self.api.account(self.groups[0])
wall_id = account["walls"][0]["id"]
return self.api.timeline_new(account["id"], wall_id)
class FanslyAPI():
ROOT = "https://apiv3.fansly.com"
def __init__(self, extractor):
self.extractor = extractor
token = extractor.config("token")
if not token:
self.extractor.log.warning("No 'token' provided")
self.headers = {
"fansly-client-ts": None,
"Origin" : extractor.root,
"authorization" : token,
}
def account(self, username):
endpoint = "/v1/account"
params = {"usernames": username}
return self._call(endpoint, params)["response"][0]
def post(self, post_id):
endpoint = "/v1/post"
params = {"ids": post_id}
return self._update_posts(self._call(endpoint, params))
def timeline_new(self, account_id, wall_id):
endpoint = f"/v1/timelinenew/{account_id}"
params = {
"before" : "0",
"after" : "0",
"wallId" : wall_id,
"contentSearch": "",
}
return self._pagination(endpoint, params)
def _update_posts(self, data):
response = data["response"]
accounts = {
account["id"]: account
for account in response["accounts"]
}
media = {
media["id"]: media
for media in response["accountMedia"]
}
bundles = {
bundle["id"]: bundle
for bundle in response["accountMediaBundles"]
}
posts = response["posts"]
for post in posts:
post["account"] = accounts[post.pop("accountId")]
att = []
for attachment in post["attachments"]:
cid = attachment["contentId"]
if cid in media:
att.append(media[cid])
elif cid in bundles:
content = bundles[cid]["bundleContent"]
content.sort(key=lambda c: c["pos"])
att.extend(
media[c["accountMediaId"]]
for c in content
)
post["attachments"] = att
return posts
def _call(self, endpoint, params):
url = f"{self.ROOT}/api{endpoint}"
params["ngsw-bypass"] = "true"
headers = self.headers.copy()
headers["fansly-client-ts"] = str(int(time.time() * 1000))
return self.extractor.request_json(url, params=params, headers=headers)
def _pagination(self, endpoint, params):
while True:
data = self._call(endpoint, params)
posts = self._update_posts(data)
if not posts:
return
yield from posts
params["before"] = min(p["id"] for p in posts)

28
test/results/fansly.py Normal file
View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
from gallery_dl.extractor import fansly
__tests__ = (
{
"#url" : "https://fansly.com/post/819035448046268416",
"#comment" : "video",
"#class" : fansly.FanslyPostExtractor,
},
{
"#url" : "https://fansly.com/post/815337432600821760",
"#comment" : "images",
"#class" : fansly.FanslyPostExtractor,
},
{
"#url" : "https://fansly.com/Oliviaus/posts",
"#class" : fansly.FanslyCreatorPostsExtractor,
},
)