[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:
@@ -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
|
||||
|
||||
@@ -328,6 +328,10 @@
|
||||
"fee-max" : null,
|
||||
"metadata": false
|
||||
},
|
||||
"fansly":
|
||||
{
|
||||
"token": ""
|
||||
},
|
||||
"flickr":
|
||||
{
|
||||
"access-token" : null,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,6 +56,7 @@ modules = [
|
||||
"exhentai",
|
||||
"facebook",
|
||||
"fanbox",
|
||||
"fansly",
|
||||
"fantia",
|
||||
"fapello",
|
||||
"fapachi",
|
||||
|
||||
213
gallery_dl/extractor/fansly.py
Normal file
213
gallery_dl/extractor/fansly.py
Normal 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
28
test/results/fansly.py
Normal 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,
|
||||
},
|
||||
|
||||
)
|
||||
Reference in New Issue
Block a user