- 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
214 lines
6.4 KiB
Python
214 lines
6.4 KiB
Python
# -*- 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)
|