Merge branch 'res'

This commit is contained in:
Mike Fährmann
2026-02-01 19:52:20 +01:00
22 changed files with 2078 additions and 2005 deletions

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2019-2025 Mike Fährmann
# Copyright 2019-2026 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
@@ -83,7 +83,7 @@ class _500pxExtractor(Extractor):
data = {
"operationName": opname,
"variables" : util.json_dumps(variables),
"query" : QUERIES[opname],
"query" : self.utils("graphql", opname),
}
return self.request_json(
url, method="POST", headers=headers, json=data)["data"]
@@ -212,512 +212,3 @@ class _500pxImageExtractor(_500pxExtractor):
def photos(self):
edges = ({"node": {"legacyId": self.photo_id}},)
return self._extend(edges)
QUERIES = {
"OtherPhotosQuery": """\
query OtherPhotosQuery($username: String!, $pageSize: Int) {
user: userByUsername(username: $username) {
...OtherPhotosPaginationContainer_user_RlXb8
id
}
}
fragment OtherPhotosPaginationContainer_user_RlXb8 on User {
photos(first: $pageSize, privacy: PROFILE, sort: ID_DESC) {
edges {
node {
id
legacyId
canonicalPath
width
height
name
isLikedByMe
notSafeForWork
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
followedByUsers {
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
""",
"OtherPhotosPaginationContainerQuery": """\
query OtherPhotosPaginationContainerQuery($username: String!, $pageSize: Int, $cursor: String) {
userByUsername(username: $username) {
...OtherPhotosPaginationContainer_user_3e6UuE
id
}
}
fragment OtherPhotosPaginationContainer_user_3e6UuE on User {
photos(first: $pageSize, after: $cursor, privacy: PROFILE, sort: ID_DESC) {
edges {
node {
id
legacyId
canonicalPath
width
height
name
isLikedByMe
notSafeForWork
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
followedByUsers {
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
""",
"ProfileRendererQuery": """\
query ProfileRendererQuery($username: String!) {
profile: userByUsername(username: $username) {
id
legacyId
userType: type
username
firstName
displayName
registeredAt
canonicalPath
avatar {
...ProfileAvatar_avatar
id
}
userProfile {
firstname
lastname
state
country
city
about
id
}
socialMedia {
website
twitter
instagram
facebook
id
}
coverPhotoUrl
followedByUsers {
totalCount
isFollowedByMe
}
followingUsers {
totalCount
}
membership {
expiryDate
membershipTier: tier
photoUploadQuota
refreshPhotoUploadQuotaAt
paymentStatus
id
}
profileTabs {
tabs {
name
visible
}
}
...EditCover_cover
photoStats {
likeCount
viewCount
}
photos(privacy: PROFILE) {
totalCount
}
licensingPhotos(status: ACCEPTED) {
totalCount
}
portfolio {
id
status
userDisabled
}
}
}
fragment EditCover_cover on User {
coverPhotoUrl
}
fragment ProfileAvatar_avatar on UserAvatar {
images(sizes: [MEDIUM, LARGE]) {
size
url
id
}
}
""",
"GalleriesDetailQueryRendererQuery": """\
query GalleriesDetailQueryRendererQuery($galleryOwnerLegacyId: ID!, $ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $gallerySize: Int) {
galleries(galleryOwnerLegacyId: $galleryOwnerLegacyId, first: $gallerySize) {
edges {
node {
legacyId
description
name
privacy
canonicalPath
notSafeForWork
buttonName
externalUrl
cover {
images(sizes: [35, 33]) {
size
webpUrl
jpegUrl
id
}
id
}
photos {
totalCount
}
id
}
}
}
gallery: galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {
...GalleriesDetailPaginationContainer_gallery_RlXb8
id
}
}
fragment GalleriesDetailPaginationContainer_gallery_RlXb8 on Gallery {
id
legacyId
name
privacy
notSafeForWork
ownPhotosOnly
canonicalPath
publicSlug
lastPublishedAt
photosAddedSinceLastPublished
reportStatus
creator {
legacyId
id
}
cover {
images(sizes: [33, 32, 36, 2048]) {
url
size
webpUrl
id
}
id
}
description
externalUrl
buttonName
photos(first: $pageSize) {
totalCount
edges {
cursor
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images(sizes: SMALL) {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 32]) {
size
url
webpUrl
id
}
__typename
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
""",
"GalleriesDetailPaginationContainerQuery": """\
query GalleriesDetailPaginationContainerQuery($ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $cursor: String) {
galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {
...GalleriesDetailPaginationContainer_gallery_3e6UuE
id
}
}
fragment GalleriesDetailPaginationContainer_gallery_3e6UuE on Gallery {
id
legacyId
name
privacy
notSafeForWork
ownPhotosOnly
canonicalPath
publicSlug
lastPublishedAt
photosAddedSinceLastPublished
reportStatus
creator {
legacyId
id
}
cover {
images(sizes: [33, 32, 36, 2048]) {
url
size
webpUrl
id
}
id
}
description
externalUrl
buttonName
photos(first: $pageSize, after: $cursor) {
totalCount
edges {
cursor
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images(sizes: SMALL) {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 32]) {
size
url
webpUrl
id
}
__typename
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
""",
"LikedPhotosQueryRendererQuery": """\
query LikedPhotosQueryRendererQuery($pageSize: Int) {
...LikedPhotosPaginationContainer_query_RlXb8
}
fragment LikedPhotosPaginationContainer_query_RlXb8 on Query {
likedPhotos(first: $pageSize) {
edges {
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
notSafeForWork
tags
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
""",
"LikedPhotosPaginationContainerQuery": """\
query LikedPhotosPaginationContainerQuery($cursor: String, $pageSize: Int) {
...LikedPhotosPaginationContainer_query_3e6UuE
}
fragment LikedPhotosPaginationContainer_query_3e6UuE on Query {
likedPhotos(first: $pageSize, after: $cursor) {
edges {
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
notSafeForWork
tags
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
""",
}

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2018-2025 Mike Fährmann
# Copyright 2018-2026 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
@@ -42,7 +42,7 @@ class BehanceExtractor(Extractor):
"X-Requested-With": "XMLHttpRequest",
}
data = {
"query" : GRAPHQL_QUERIES[endpoint],
"query" : self.utils("graphql", endpoint),
"variables": variables,
}
@@ -284,415 +284,3 @@ class BehanceCollectionExtractor(BehanceExtractor):
if not items["pageInfo"]["hasNextPage"]:
return
variables["afterItem"] = items["pageInfo"]["endCursor"]
GRAPHQL_QUERIES = {
"GetProfileProjects": """\
query GetProfileProjects($username: String, $after: String) {
user(username: $username) {
profileProjects(first: 12, after: $after) {
pageInfo {
endCursor
hasNextPage
}
nodes {
__typename
adminFlags {
mature_lock
privacy_lock
dmca_lock
flagged_lock
privacy_violation_lock
trademark_lock
spam_lock
eu_ip_lock
}
colors {
r
g
b
}
covers {
size_202 {
url
}
size_404 {
url
}
size_808 {
url
}
}
features {
url
name
featuredOn
ribbon {
image
image2x
image3x
}
}
fields {
id
label
slug
url
}
hasMatureContent
id
isFeatured
isHiddenFromWorkTab
isMatureReviewSubmitted
isOwner
isFounder
isPinnedToSubscriptionOverview
isPrivate
linkedAssets {
...sourceLinkFields
}
linkedAssetsCount
sourceFiles {
...sourceFileFields
}
matureAccess
modifiedOn
name
owners {
...OwnerFields
images {
size_50 {
url
}
}
}
premium
publishedOn
stats {
appreciations {
all
}
views {
all
}
comments {
all
}
}
slug
tools {
id
title
category
categoryLabel
categoryId
approved
url
backgroundColor
}
url
}
}
}
}
fragment sourceFileFields on SourceFile {
__typename
sourceFileId
projectId
userId
title
assetId
renditionUrl
mimeType
size
category
licenseType
unitAmount
currency
tier
hidden
extension
hasUserPurchased
}
fragment sourceLinkFields on LinkedAsset {
__typename
name
premium
url
category
licenseType
}
fragment OwnerFields on User {
displayName
hasPremiumAccess
id
isFollowing
isProfileOwner
location
locationUrl
url
username
availabilityInfo {
availabilityTimeline
isAvailableFullTime
isAvailableFreelance
}
}
""",
"GetMoodboardItemsAndRecommendations": """\
query GetMoodboardItemsAndRecommendations(
$id: Int!
$firstItem: Int!
$afterItem: String
$shouldGetRecommendations: Boolean!
$shouldGetItems: Boolean!
$shouldGetMoodboardFields: Boolean!
) {
viewer @include(if: $shouldGetMoodboardFields) {
isOptedOutOfRecommendations
isAdmin
}
moodboard(id: $id) {
...moodboardFields @include(if: $shouldGetMoodboardFields)
items(first: $firstItem, after: $afterItem) @include(if: $shouldGetItems) {
pageInfo {
endCursor
hasNextPage
}
nodes {
...nodesFields
}
}
recommendedItems(first: 80) @include(if: $shouldGetRecommendations) {
nodes {
...nodesFields
fetchSource
}
}
}
}
fragment moodboardFields on Moodboard {
id
label
privacy
followerCount
isFollowing
projectCount
url
isOwner
owners {
...OwnerFields
images {
size_50 {
url
}
size_100 {
url
}
size_115 {
url
}
size_230 {
url
}
size_138 {
url
}
size_276 {
url
}
}
}
}
fragment projectFields on Project {
__typename
id
isOwner
publishedOn
matureAccess
hasMatureContent
modifiedOn
name
url
isPrivate
slug
license {
license
description
id
label
url
text
images
}
fields {
label
}
colors {
r
g
b
}
owners {
...OwnerFields
images {
size_50 {
url
}
size_100 {
url
}
size_115 {
url
}
size_230 {
url
}
size_138 {
url
}
size_276 {
url
}
}
}
covers {
size_original {
url
}
size_max_808 {
url
}
size_808 {
url
}
size_404 {
url
}
size_202 {
url
}
size_230 {
url
}
size_115 {
url
}
}
stats {
views {
all
}
appreciations {
all
}
comments {
all
}
}
}
fragment exifDataValueFields on exifDataValue {
id
label
value
searchValue
}
fragment nodesFields on MoodboardItem {
id
entityType
width
height
flexWidth
flexHeight
images {
size
url
}
entity {
... on Project {
...projectFields
}
... on ImageModule {
project {
...projectFields
}
colors {
r
g
b
}
exifData {
lens {
...exifDataValueFields
}
software {
...exifDataValueFields
}
makeAndModel {
...exifDataValueFields
}
focalLength {
...exifDataValueFields
}
iso {
...exifDataValueFields
}
location {
...exifDataValueFields
}
flash {
...exifDataValueFields
}
exposureMode {
...exifDataValueFields
}
shutterSpeed {
...exifDataValueFields
}
aperture {
...exifDataValueFields
}
}
}
... on MediaCollectionComponent {
project {
...projectFields
}
}
}
}
fragment OwnerFields on User {
displayName
hasPremiumAccess
id
isFollowing
isProfileOwner
location
locationUrl
url
username
availabilityInfo {
availabilityTimeline
isAvailableFullTime
isAvailableFreelance
}
}
""",
}

View File

@@ -354,6 +354,17 @@ class Extractor():
seconds, reason)
time.sleep(seconds)
def utils(self, module="", name=None):
module = (self.__class__.category if not module else
module[1:] if module[0] == "/" else
f"{self.__class__.category}_{module}")
if module in CACHE_UTILS:
res = CACHE_UTILS[module]
else:
res = CACHE_UTILS[module] = __import__(
"utils." + module, globals(), None, module, 1)
return res if name is None else getattr(res, name, None)
def input(self, prompt, echo=True):
self._check_input_allowed(prompt)
@@ -1128,6 +1139,7 @@ def _browser_useragent(browser):
CACHE_ADAPTERS = {}
CACHE_COOKIES = {}
CACHE_UTILS = {}
CATEGORY_MAP = ()

View File

@@ -292,7 +292,8 @@ class DeviantartExtractor(Extractor):
url = deviation["url"]
thumbs = deviation.get("thumbs") or deviation.get("files")
html = journal["html"]
shadow = SHADOW_TEMPLATE.format_map(thumbs[0]) if thumbs else ""
tmpl = self.utils("journal")
shadow = tmpl.SHADOW.format_map(thumbs[0]) if thumbs else ""
if not html:
self.log.warning("%s: Empty journal content", deviation["index"])
@@ -308,14 +309,14 @@ class DeviantartExtractor(Extractor):
if html.find('<div class="boxtop journaltop">', 0, 250) != -1:
needle = '<div class="boxtop journaltop">'
header = HEADER_CUSTOM_TEMPLATE.format(
header = tmpl.HEADER_CUSTOM.format(
title=title, url=url, date=deviation["date"],
)
else:
needle = '<div usr class="gr">'
username = deviation["author"]["username"]
urlname = deviation.get("username") or username.lower()
header = HEADER_TEMPLATE.format(
header = tmpl.HEADER.format(
title=title,
url=url,
userurl=f"{self.root}/{urlname}/",
@@ -326,9 +327,9 @@ class DeviantartExtractor(Extractor):
if needle in html:
html = html.replace(needle, header, 1)
else:
html = JOURNAL_TEMPLATE_HTML_EXTRA.format(header, html)
html = tmpl.HTML_EXTRA.format(header, html)
html = JOURNAL_TEMPLATE_HTML.format(
html = tmpl.HTML.format(
title=title, html=html, shadow=shadow, css=css, cls=cls)
deviation["extension"] = "htm"
@@ -345,7 +346,7 @@ class DeviantartExtractor(Extractor):
text.unescape(text.remove_html(txt))
for txt in (head or tail).split("<br />")
)
txt = JOURNAL_TEMPLATE_TEXT.format(
txt = self.utils("journal").TEXT.format(
title=deviation["title"],
username=deviation["author"]["username"],
date=deviation["date"],
@@ -402,7 +403,7 @@ class DeviantartExtractor(Extractor):
if html["type"] == "tiptap":
try:
return self._tiptap_to_html(markup)
return self.utils("tiptap").to_html(markup)
except Exception as exc:
self.log.traceback(exc)
self.log.error("%s: '%s: %s'", deviation["index"],
@@ -411,242 +412,6 @@ class DeviantartExtractor(Extractor):
self.log.warning("%s: Unsupported '%s' markup.",
deviation["index"], html["type"])
def _tiptap_to_html(self, markup):
html = []
html.append('<div data-editor-viewer="1" '
'class="_83r8m _2CKTq _3NjDa mDnFl">')
data = util.json_loads(markup)
for block in data["document"]["content"]:
self._tiptap_process_content(html, block)
html.append("</div>")
return "".join(html)
def _tiptap_process_content(self, html, content):
type = content["type"]
if type == "paragraph":
if children := content.get("content"):
html.append('<p style="')
if attrs := content.get("attrs"):
if align := attrs.get("textAlign"):
html.append("text-align:")
html.append(align)
html.append(";")
self._tiptap_process_indentation(html, attrs)
html.append('">')
else:
html.append('margin-inline-start:0px">')
for block in children:
self._tiptap_process_content(html, block)
html.append("</p>")
else:
html.append('<p class="empty-p"><br/></p>')
elif type == "text":
self._tiptap_process_text(html, content)
elif type == "heading":
attrs = content["attrs"]
level = str(attrs.get("level") or "3")
html.append("<h")
html.append(level)
html.append(' style="text-align:')
html.append(attrs.get("textAlign") or "left")
html.append('">')
html.append('<span style="')
self._tiptap_process_indentation(html, attrs)
html.append('">')
self._tiptap_process_children(html, content)
html.append("</span></h")
html.append(level)
html.append(">")
elif type in ("listItem", "bulletList", "orderedList", "blockquote"):
c = type[1]
tag = (
"li" if c == "i" else
"ul" if c == "u" else
"ol" if c == "r" else
"blockquote"
)
html.append("<" + tag + ">")
self._tiptap_process_children(html, content)
html.append("</" + tag + ">")
elif type == "anchor":
attrs = content["attrs"]
html.append('<a id="')
html.append(attrs.get("id") or "")
html.append('" data-testid="anchor"></a>')
elif type == "hardBreak":
html.append("<br/><br/>")
elif type == "horizontalRule":
html.append("<hr/>")
elif type == "da-deviation":
self._tiptap_process_deviation(html, content)
elif type == "da-mention":
user = content["attrs"]["user"]["username"]
html.append('<a href="https://www.deviantart.com/')
html.append(user.lower())
html.append('" data-da-type="da-mention" data-user="">@<!-- -->')
html.append(user)
html.append('</a>')
elif type == "da-gif":
attrs = content["attrs"]
width = str(attrs.get("width") or "")
height = str(attrs.get("height") or "")
url = text.escape(attrs.get("url") or "")
html.append('<div data-da-type="da-gif" data-width="')
html.append(width)
html.append('" data-height="')
html.append(height)
html.append('" data-alignment="')
html.append(attrs.get("alignment") or "")
html.append('" data-url="')
html.append(url)
html.append('" class="t61qu"><video role="img" autoPlay="" '
'muted="" loop="" style="pointer-events:none" '
'controlsList="nofullscreen" playsInline="" '
'aria-label="gif" data-da-type="da-gif" width="')
html.append(width)
html.append('" height="')
html.append(height)
html.append('" src="')
html.append(url)
html.append('" class="_1Fkk6"></video></div>')
elif type == "da-video":
src = text.escape(content["attrs"].get("src") or "")
html.append('<div data-testid="video" data-da-type="da-video" '
'data-src="')
html.append(src)
html.append('" class="_1Uxvs"><div data-canfs="yes" data-testid="v'
'ideo-inner" class="main-video" style="width:780px;hei'
'ght:438px"><div style="width:780px;height:438px">'
'<video src="')
html.append(src)
html.append('" style="width:100%;height:100%;" preload="auto" cont'
'rols=""></video></div></div></div>')
else:
self.log.warning("Unsupported content type '%s'", type)
def _tiptap_process_text(self, html, content):
if marks := content.get("marks"):
close = []
for mark in marks:
type = mark["type"]
if type == "link":
attrs = mark.get("attrs") or {}
html.append('<a href="')
html.append(text.escape(attrs.get("href") or ""))
if "target" in attrs:
html.append('" target="')
html.append(attrs["target"])
html.append('" rel="')
html.append(attrs.get("rel") or
"noopener noreferrer nofollow ugc")
html.append('">')
close.append("</a>")
elif type == "bold":
html.append("<strong>")
close.append("</strong>")
elif type == "italic":
html.append("<em>")
close.append("</em>")
elif type == "underline":
html.append("<u>")
close.append("</u>")
elif type == "strike":
html.append("<s>")
close.append("</s>")
elif type == "textStyle" and len(mark) <= 1:
pass
else:
self.log.warning("Unsupported text marker '%s'", type)
close.reverse()
html.append(text.escape(content["text"]))
html.extend(close)
else:
html.append(text.escape(content["text"]))
def _tiptap_process_children(self, html, content):
if children := content.get("content"):
for block in children:
self._tiptap_process_content(html, block)
def _tiptap_process_indentation(self, html, attrs):
itype = ("text-indent" if attrs.get("indentType") == "line" else
"margin-inline-start")
isize = str((attrs.get("indentation") or 0) * 24)
html.append(itype + ":" + isize + "px")
def _tiptap_process_deviation(self, html, content):
dev = content["attrs"]["deviation"]
media = dev.get("media") or ()
html.append('<div class="jjNX2">')
html.append('<figure class="Qf-HY" data-da-type="da-deviation" '
'data-deviation="" '
'data-width="" data-link="" data-alignment="center">')
if "baseUri" in media:
url, formats = self._eclipse_media(media)
full = formats["fullview"]
html.append('<a href="')
html.append(text.escape(dev["url"]))
html.append('" class="_3ouD5" style="margin:0 auto;display:flex;'
'align-items:center;justify-content:center;'
'overflow:hidden;width:780px;height:')
html.append(str(780 * full["h"] / full["w"]))
html.append('px">')
html.append('<img src="')
html.append(text.escape(url))
html.append('" alt="')
html.append(text.escape(dev["title"]))
html.append('" style="width:100%;max-width:100%;display:block"/>')
html.append("</a>")
elif "textContent" in dev:
html.append('<div class="_32Hs4" style="width:350px">')
html.append('<a href="')
html.append(text.escape(dev["url"]))
html.append('" class="_3ouD5">')
html.append('''\
<section class="Q91qI aG7Yi" style="width:350px;height:313px">\
<div class="_16ECM _1xMkk" aria-hidden="true">\
<svg height="100%" viewBox="0 0 15 12" preserveAspectRatio="xMidYMin slice" \
fill-rule="evenodd">\
<linearGradient x1="87.8481761%" y1="16.3690766%" \
x2="45.4107524%" y2="71.4898596%" id="app-root-3">\
<stop stop-color="#00FF62" offset="0%"></stop>\
<stop stop-color="#3197EF" stop-opacity="0" offset="100%"></stop>\
</linearGradient>\
<text class="_2uqbc" fill="url(#app-root-3)" text-anchor="end" x="15" y="11">J\
</text></svg></div><div class="_1xz9u">Literature</div><h3 class="_2WvKD">\
''')
html.append(text.escape(dev["title"]))
html.append('</h3><div class="_2CPLm">')
html.append(text.escape(dev["textContent"]["excerpt"]))
html.append('</div></section></a></div>')
html.append('</figure></div>')
def _extract_content(self, deviation):
content = deviation["content"]
@@ -827,25 +592,6 @@ x2="45.4107524%" y2="71.4898596%" id="app-root-3">\
self.log.info("Unwatching %s", username)
self.api.user_friends_unwatch(username)
def _eclipse_media(self, media, format="preview"):
url = [media["baseUri"]]
formats = {
fmt["t"]: fmt
for fmt in media["types"]
}
if tokens := media.get("token") or ():
if len(tokens) <= 1:
fmt = formats[format]
if "c" in fmt:
url.append(fmt["c"].replace(
"<prettyName>", media["prettyName"]))
url.append("?token=")
url.append(tokens[-1])
return "".join(url), formats
def _eclipse_to_oauth(self, eclipse_api, deviations):
for obj in deviations:
deviation = obj["deviation"] if "deviation" in obj else obj
@@ -1303,7 +1049,7 @@ class DeviantartDeviationExtractor(DeviantartExtractor):
yield deviation
for index, post in enumerate(additional_media):
uri = self._eclipse_media(post["media"], "fullview")[0]
uri = eclipse_media(post["media"], "fullview")[0]
deviation["content"]["src"] = uri
deviation["num"] += 1
deviation["index_file"] = post["fileId"]
@@ -2124,6 +1870,9 @@ def _login_impl(extr, username, password):
}
_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
def id_from_base36(base36):
return util.bdecode(base36, _ALPHABET)
@@ -2132,116 +1881,21 @@ def base36_from_id(deviation_id):
return util.bencode(int(deviation_id), _ALPHABET)
_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
def eclipse_media(media, format="preview"):
url = [media["baseUri"]]
formats = {
fmt["t"]: fmt
for fmt in media["types"]
}
###############################################################################
# Journal Formats #############################################################
if tokens := media.get("token") or ():
if len(tokens) <= 1:
fmt = formats[format]
if "c" in fmt:
url.append(fmt["c"].replace(
"<prettyName>", media["prettyName"]))
url.append("?token=")
url.append(tokens[-1])
SHADOW_TEMPLATE = """
<span class="shadow">
<img src="{src}" class="smshadow" width="{width}" height="{height}">
</span>
<br><br>
"""
HEADER_TEMPLATE = """<div usr class="gr">
<div class="metadata">
<h2><a href="{url}">{title}</a></h2>
<ul>
<li class="author">
by <span class="name"><span class="username-with-symbol u">
<a class="u regular username" href="{userurl}">{username}</a>\
<span class="user-symbol regular"></span></span></span>,
<span>{date}</span>
</li>
</ul>
</div>
"""
HEADER_CUSTOM_TEMPLATE = """<div class='boxtop journaltop'>
<h2>
<img src="https://st.deviantart.net/minish/gruzecontrol/icons/journal.gif\
?2" style="vertical-align:middle" alt=""/>
<a href="{url}">{title}</a>
</h2>
Journal Entry: <span>{date}</span>
"""
JOURNAL_TEMPLATE_HTML = """text:<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{title}</title>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/deviantart-network_lc.css?3843780832"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/group_secrets_lc.css?3250492874"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/v6core_lc.css?4246581581"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/sidebar_lc.css?1490570941"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/writer_lc.css?3090682151"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/v6loggedin_lc.css?3001430805"/>
<style>{css}</style>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/core.css?1488405371919"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/peeky.css?1487067424177"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/desktop.css?1491362542749"/>
<link rel="stylesheet" href="https://static.parastorage.com/services\
/da-deviation/2bfd1ff7a9d6bf10d27b98dd8504c0399c3f9974a015785114b7dc6b\
/app.min.css"/>
</head>
<body id="deviantART-v7" class="bubble no-apps loggedout w960 deviantart">
<div id="output">
<div class="dev-page-container bubbleview">
<div class="dev-page-view view-mode-normal">
<div class="dev-view-main-content">
<div class="dev-view-deviation">
{shadow}
<div class="journal-wrapper tt-a">
<div class="journal-wrapper2">
<div class="journal {cls} journalcontrol">
{html}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
"""
JOURNAL_TEMPLATE_HTML_EXTRA = """\
<div id="devskin0"><div class="negate-box-margin" style="">\
<div usr class="gr-box gr-genericbox"
><i usr class="gr1"><i></i></i
><i usr class="gr2"><i></i></i
><i usr class="gr3"><i></i></i
><div usr class="gr-top">
<i usr class="tri"></i>
{}
</div>
</div><div usr class="gr-body"><div usr class="gr">
<div class="grf-indent">
<div class="text">
{} </div>
</div>
</div></div>
<i usr class="gr3 gb"></i>
<i usr class="gr2 gb"></i>
<i usr class="gr1 gb gb1"></i> </div>
</div></div>"""
JOURNAL_TEMPLATE_TEXT = """text:{title}
by {username}, {date}
{content}
"""
return "".join(url), formats

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2016-2025 Mike Fährmann
# Copyright 2016-2026 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
@@ -18,15 +18,15 @@ class LusciousExtractor(Extractor):
cookies_domain = ".luscious.net"
root = "https://members.luscious.net"
def _graphql(self, op, variables, query):
def _request_graphql(self, opname, variables):
data = {
"id" : 1,
"operationName": op,
"query" : query,
"operationName": opname,
"query" : self.utils("graphql", opname),
"variables" : variables,
}
response = self.request(
f"{self.root}/graphql/nobatch/?operationName={op}",
f"{self.root}/graphql/nobatch/?operationName={opname}",
method="POST", json=data, fatal=False,
)
@@ -49,11 +49,8 @@ class LusciousAlbumExtractor(LusciousExtractor):
r"/(?:albums|pictures/c/[^/?#]+/album)/[^/?#]+_(\d+)")
example = "https://luscious.net/albums/TITLE_12345/"
def __init__(self, match):
LusciousExtractor.__init__(self, match)
self.album_id = match[1]
def _init(self):
self.album_id = self.groups[0]
self.gif = self.config("gif", False)
def items(self):
@@ -83,98 +80,7 @@ class LusciousAlbumExtractor(LusciousExtractor):
"id": self.album_id,
}
query = """
query AlbumGet($id: ID!) {
album {
get(id: $id) {
... on Album {
...AlbumStandard
}
... on MutationError {
errors {
code
message
}
}
}
}
}
fragment AlbumStandard on Album {
__typename
id
title
labels
description
created
modified
like_status
number_of_favorites
rating
status
marked_for_deletion
marked_for_processing
number_of_pictures
number_of_animated_pictures
slug
is_manga
url
download_url
permissions
cover {
width
height
size
url
}
created_by {
id
name
display_name
user_title
avatar {
url
size
}
url
}
content {
id
title
url
}
language {
id
title
url
}
tags {
id
category
text
url
count
}
genres {
id
title
slug
url
}
audiences {
id
title
url
url
}
last_viewed_picture {
id
position
url
}
}
"""
album = self._graphql("AlbumGet", variables, query)["album"]["get"]
album = self._request_graphql("AlbumGet", variables)["album"]["get"]
if "errors" in album:
raise exception.NotFoundError("album")
@@ -204,66 +110,8 @@ fragment AlbumStandard on Album {
},
}
query = """
query AlbumListOwnPictures($input: PictureListInput!) {
picture {
list(input: $input) {
info {
...FacetCollectionInfo
}
items {
...PictureStandardWithoutAlbum
}
}
}
}
fragment FacetCollectionInfo on FacetCollectionInfo {
page
has_next_page
has_previous_page
total_items
total_pages
items_per_page
url_complete
url_filters_only
}
fragment PictureStandardWithoutAlbum on Picture {
__typename
id
title
created
like_status
number_of_comments
number_of_favorites
status
width
height
resolution
aspect_ratio
url_to_original
url_to_video
is_animated
position
tags {
id
category
text
url
}
permissions
url
thumbnails {
width
height
size
url
}
}
"""
while True:
data = self._graphql("AlbumListOwnPictures", variables, query)
data = self._request_graphql("AlbumListOwnPictures", variables)
yield from data["picture"]["list"]["items"]
if not data["picture"]["list"]["info"]["has_next_page"]:
@@ -278,12 +126,8 @@ class LusciousSearchExtractor(LusciousExtractor):
r"/albums/list/?(?:\?([^#]+))?")
example = "https://luscious.net/albums/list/?tagged=TAG"
def __init__(self, match):
LusciousExtractor.__init__(self, match)
self.query = match[1]
def items(self):
query = text.parse_query(self.query)
query = text.parse_query(self.groups[0])
display = query.pop("display", "date_newest")
page = query.pop("page", None)
@@ -295,89 +139,8 @@ class LusciousSearchExtractor(LusciousExtractor):
},
}
query = """
query AlbumListWithPeek($input: AlbumListInput!) {
album {
list(input: $input) {
info {
...FacetCollectionInfo
}
items {
...AlbumMinimal
peek_thumbnails {
width
height
size
url
}
}
}
}
}
fragment FacetCollectionInfo on FacetCollectionInfo {
page
has_next_page
has_previous_page
total_items
total_pages
items_per_page
url_complete
url_filters_only
}
fragment AlbumMinimal on Album {
__typename
id
title
labels
description
created
modified
number_of_favorites
number_of_pictures
slug
is_manga
url
download_url
cover {
width
height
size
url
}
content {
id
title
url
}
language {
id
title
url
}
tags {
id
category
text
url
count
}
genres {
id
title
slug
url
}
audiences {
id
title
url
}
}
"""
while True:
data = self._graphql("AlbumListWithPeek", variables, query)
data = self._request_graphql("AlbumListWithPeek", variables)
for album in data["album"]["list"]["items"]:
album["url"] = self.root + album["url"]

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2015-2025 Mike Fährmann
# Copyright 2015-2026 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
@@ -60,7 +60,7 @@ class MangaparkBase():
def _request_graphql(self, opname, variables):
url = self.root + "/apo/"
data = {
"query" : QUERIES[opname],
"query" : self.utils("graphql", opname),
"variables" : variables,
"operationName": opname,
}
@@ -177,183 +177,3 @@ class MangaparkMangaExtractor(MangaparkBase, Extractor):
raise exception.AbortExtraction(
f"'{source}' does not match any available source")
QUERIES = {
"Get_comicChapterList": """
query Get_comicChapterList($comicId: ID!) {
get_comicChapterList(comicId: $comicId) {
data {
id
dname
title
lang
urlPath
srcTitle
sourceId
dateCreate
}
}
}
""",
"Get_chapterNode": """
query Get_chapterNode($getChapterNodeId: ID!) {
get_chapterNode(id: $getChapterNodeId) {
data {
id
dname
lang
sourceId
srcTitle
dateCreate
comicNode{
id
}
imageFile {
urlList
}
}
}
}
""",
"Get_comicNode": """
query Get_comicNode($getComicNodeId: ID!) {
get_comicNode(id: $getComicNodeId) {
data {
id
name
artists
authors
genres
}
}
}
""",
"get_content_source_chapterList": """
query get_content_source_chapterList($sourceId: Int!) {
get_content_source_chapterList(
sourceId: $sourceId
) {
id
data {
id
sourceId
dbStatus
isNormal
isHidden
isDeleted
isFinal
dateCreate
datePublic
dateModify
lang
volume
serial
dname
title
urlPath
srcTitle srcColor
count_images
stat_count_post_child
stat_count_post_reply
stat_count_views_login
stat_count_views_guest
userId
userNode {
id
data {
id
name
uniq
avatarUrl
urlPath
verified
deleted
banned
dateCreate
dateOnline
stat_count_chapters_normal
stat_count_chapters_others
is_adm is_mod is_vip is_upr
}
}
disqusId
}
}
}
""",
"get_content_comic_sources": """
query get_content_comic_sources($comicId: Int!, $dbStatuss: [String] = [], $userId: Int, $haveChapter: Boolean, $sortFor: String) {
get_content_comic_sources(
comicId: $comicId
dbStatuss: $dbStatuss
userId: $userId
haveChapter: $haveChapter
sortFor: $sortFor
) {
id
data{
id
dbStatus
isNormal
isHidden
isDeleted
lang name altNames authors artists
release
genres summary{code} extraInfo{code}
urlCover600
urlCover300
urlCoverOri
srcTitle srcColor
chapterCount
chapterNode_last {
id
data {
dateCreate datePublic dateModify
volume serial
dname title
urlPath
userNode {
id data {uniq name}
}
}
}
}
}
}
""",
}

View File

@@ -98,7 +98,7 @@ class ScrolllerExtractor(Extractor):
"Sec-Fetch-Site": "same-site",
}
data = {
"query" : QUERIES[opname],
"query" : self.utils("graphql", opname),
"variables" : variables,
"authorization": self.auth_token,
}
@@ -206,144 +206,3 @@ class ScrolllerPostExtractor(ScrolllerExtractor):
variables = {"url": "/" + self.groups[0]}
data = self._request_graphql("SubredditPostQuery", variables)
return (data["getPost"],)
QUERIES = {
"SubredditPostQuery": """\
query SubredditPostQuery(
$url: String!
) {
getPost(
data: { url: $url }
) {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource redgifsSource
ownerAvatar username displayName favoriteCount isPaid tags
commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
""",
"SubredditQuery": """\
query SubredditQuery(
$url: String!
$iterator: String
$sortBy: GallerySortBy
$filter: GalleryFilter
$limit: Int!
) {
getSubreddit(
data: {
url: $url,
iterator: $iterator,
filter: $filter,
limit: $limit,
sortBy: $sortBy
}
) {
__typename id url title secondaryTitle description createdAt isNsfw
subscribers isComplete itemCount videoCount pictureCount albumCount
isPaid username tags isFollowing
banner { url width height isOptimized }
children {
iterator items {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource
redgifsSource ownerAvatar username displayName favoriteCount
isPaid tags commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
}
}
""",
"SubredditChildrenQuery": """\
query SubredditChildrenQuery(
$subredditId: Int!
$iterator: String
$filter: GalleryFilter
$sortBy: GallerySortBy
$limit: Int!
$isNsfw: Boolean
) {
getSubredditChildren(
data: {
subredditId: $subredditId,
iterator: $iterator,
filter: $filter,
sortBy: $sortBy,
limit: $limit,
isNsfw: $isNsfw
},
) {
iterator items {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource
redgifsSource ownerAvatar username displayName favoriteCount isPaid
tags commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
}
""",
"GetFollowingSubreddits": """\
query GetFollowingSubreddits(
$iterator: String,
$limit: Int!,
$filter: GalleryFilter,
$isNsfw: Boolean,
$sortBy: GallerySortBy
) {
getFollowingSubreddits(
data: {
isNsfw: $isNsfw
limit: $limit
filter: $filter
iterator: $iterator
sortBy: $sortBy
}
) {
iterator items {
__typename id url title secondaryTitle description createdAt isNsfw
subscribers isComplete itemCount videoCount pictureCount albumCount
isFollowing
}
}
}
""",
"LoginQuery": """\
query LoginQuery(
$username: String!,
$password: String!
) {
login(
username: $username,
password: $password
) {
username token expiresAt isAdmin status isPremium
}
}
""",
"ItemTypeQuery": """\
query ItemTypeQuery(
$url: String!
) {
getItemType(
url: $url
)
}
""",
}

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2019-2025 Mike Fährmann
# Copyright 2019-2026 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
@@ -153,7 +153,7 @@ class TsuminoSearchExtractor(TsuminoBase, Extractor):
try:
if query[0] == "?":
return self._parse_simple(query)
return self._parse_jsurl(query)
return self.utils("/jsurl").parse(query)
except Exception as exc:
raise exception.AbortExtraction(
f"Invalid search query '{query}' ({exc})")
@@ -177,128 +177,3 @@ class TsuminoSearchExtractor(TsuminoBase, Extractor):
"Tags[0][Text]": text.unquote(value).replace("+", " "),
"Tags[0][Exclude]": "false",
}
def _parse_jsurl(self, data):
"""Parse search query in JSURL format
Nested lists and dicts are handled in a special way to deal
with the way Tsumino expects its parameters -> expand(...)
Example: ~(name~'John*20Doe~age~42~children~(~'Mary~'Bill))
Ref: https://github.com/Sage/jsurl
"""
i = 0
imax = len(data)
def eat(expected):
nonlocal i
if data[i] != expected:
raise ValueError(
f"bad JSURL syntax: expected '{expected}', got {data[i]}")
i += 1
def decode():
nonlocal i
beg = i
result = ""
while i < imax:
ch = data[i]
if ch not in "~)*!":
i += 1
elif ch == "*":
if beg < i:
result += data[beg:i]
if data[i + 1] == "*":
result += chr(int(data[i+2:i+6], 16))
i += 6
else:
result += chr(int(data[i+1:i+3], 16))
i += 3
beg = i
elif ch == "!":
if beg < i:
result += data[beg:i]
result += "$"
i += 1
beg = i
else:
break
return result + data[beg:i]
def parse_one():
nonlocal i
eat('~')
result = ""
ch = data[i]
if ch == "(":
i += 1
if data[i] == "~":
result = []
if data[i+1] == ")":
i += 1
else:
result.append(parse_one())
while data[i] == "~":
result.append(parse_one())
else:
result = {}
if data[i] != ")":
while True:
key = decode()
value = parse_one()
for ekey, evalue in expand(key, value):
result[ekey] = evalue
if data[i] != "~":
break
i += 1
eat(")")
elif ch == "'":
i += 1
result = decode()
else:
beg = i
i += 1
while i < imax and data[i] not in "~)":
i += 1
sub = data[beg:i]
if ch in "0123456789-":
fval = float(sub)
ival = int(fval)
result = ival if ival == fval else fval
else:
if sub not in ("true", "false", "null"):
raise ValueError("bad value keyword: " + sub)
result = sub
return result
def expand(key, value):
if isinstance(value, list):
for index, cvalue in enumerate(value):
ckey = f"{key}[{index}]"
yield from expand(ckey, cvalue)
elif isinstance(value, dict):
for ckey, cvalue in value.items():
ckey = f"{key}[{ckey}]"
yield from expand(ckey, cvalue)
else:
yield key, value
return parse_one()

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2016-2025 Mike Fährmann
# Copyright 2016-2026 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
@@ -1720,8 +1720,7 @@ class TwitterAPI():
def _client_transaction(self):
self.log.info("Initializing client transaction keys")
from .. import transaction_id
ct = transaction_id.ClientTransaction()
ct = self.extractor.utils("transaction_id").ClientTransaction()
ct.initialize(self.extractor)
# update 'x-csrf-token' header (#7467)

View File

@@ -0,0 +1,512 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
OtherPhotosQuery = """\
query OtherPhotosQuery($username: String!, $pageSize: Int) {
user: userByUsername(username: $username) {
...OtherPhotosPaginationContainer_user_RlXb8
id
}
}
fragment OtherPhotosPaginationContainer_user_RlXb8 on User {
photos(first: $pageSize, privacy: PROFILE, sort: ID_DESC) {
edges {
node {
id
legacyId
canonicalPath
width
height
name
isLikedByMe
notSafeForWork
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
followedByUsers {
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
"""
OtherPhotosPaginationContainerQuery = """\
query OtherPhotosPaginationContainerQuery($username: String!, $pageSize: Int, $cursor: String) {
userByUsername(username: $username) {
...OtherPhotosPaginationContainer_user_3e6UuE
id
}
}
fragment OtherPhotosPaginationContainer_user_3e6UuE on User {
photos(first: $pageSize, after: $cursor, privacy: PROFILE, sort: ID_DESC) {
edges {
node {
id
legacyId
canonicalPath
width
height
name
isLikedByMe
notSafeForWork
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
followedByUsers {
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
"""
ProfileRendererQuery = """\
query ProfileRendererQuery($username: String!) {
profile: userByUsername(username: $username) {
id
legacyId
userType: type
username
firstName
displayName
registeredAt
canonicalPath
avatar {
...ProfileAvatar_avatar
id
}
userProfile {
firstname
lastname
state
country
city
about
id
}
socialMedia {
website
twitter
instagram
facebook
id
}
coverPhotoUrl
followedByUsers {
totalCount
isFollowedByMe
}
followingUsers {
totalCount
}
membership {
expiryDate
membershipTier: tier
photoUploadQuota
refreshPhotoUploadQuotaAt
paymentStatus
id
}
profileTabs {
tabs {
name
visible
}
}
...EditCover_cover
photoStats {
likeCount
viewCount
}
photos(privacy: PROFILE) {
totalCount
}
licensingPhotos(status: ACCEPTED) {
totalCount
}
portfolio {
id
status
userDisabled
}
}
}
fragment EditCover_cover on User {
coverPhotoUrl
}
fragment ProfileAvatar_avatar on UserAvatar {
images(sizes: [MEDIUM, LARGE]) {
size
url
id
}
}
"""
GalleriesDetailQueryRendererQuery = """\
query GalleriesDetailQueryRendererQuery($galleryOwnerLegacyId: ID!, $ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $gallerySize: Int) {
galleries(galleryOwnerLegacyId: $galleryOwnerLegacyId, first: $gallerySize) {
edges {
node {
legacyId
description
name
privacy
canonicalPath
notSafeForWork
buttonName
externalUrl
cover {
images(sizes: [35, 33]) {
size
webpUrl
jpegUrl
id
}
id
}
photos {
totalCount
}
id
}
}
}
gallery: galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {
...GalleriesDetailPaginationContainer_gallery_RlXb8
id
}
}
fragment GalleriesDetailPaginationContainer_gallery_RlXb8 on Gallery {
id
legacyId
name
privacy
notSafeForWork
ownPhotosOnly
canonicalPath
publicSlug
lastPublishedAt
photosAddedSinceLastPublished
reportStatus
creator {
legacyId
id
}
cover {
images(sizes: [33, 32, 36, 2048]) {
url
size
webpUrl
id
}
id
}
description
externalUrl
buttonName
photos(first: $pageSize) {
totalCount
edges {
cursor
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images(sizes: SMALL) {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 32]) {
size
url
webpUrl
id
}
__typename
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
"""
GalleriesDetailPaginationContainerQuery = """\
query GalleriesDetailPaginationContainerQuery($ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $cursor: String) {
galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {
...GalleriesDetailPaginationContainer_gallery_3e6UuE
id
}
}
fragment GalleriesDetailPaginationContainer_gallery_3e6UuE on Gallery {
id
legacyId
name
privacy
notSafeForWork
ownPhotosOnly
canonicalPath
publicSlug
lastPublishedAt
photosAddedSinceLastPublished
reportStatus
creator {
legacyId
id
}
cover {
images(sizes: [33, 32, 36, 2048]) {
url
size
webpUrl
id
}
id
}
description
externalUrl
buttonName
photos(first: $pageSize, after: $cursor) {
totalCount
edges {
cursor
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images(sizes: SMALL) {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 32]) {
size
url
webpUrl
id
}
__typename
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
"""
LikedPhotosQueryRendererQuery = """\
query LikedPhotosQueryRendererQuery($pageSize: Int) {
...LikedPhotosPaginationContainer_query_RlXb8
}
fragment LikedPhotosPaginationContainer_query_RlXb8 on Query {
likedPhotos(first: $pageSize) {
edges {
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
notSafeForWork
tags
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
"""
LikedPhotosPaginationContainerQuery = """\
query LikedPhotosPaginationContainerQuery($cursor: String, $pageSize: Int) {
...LikedPhotosPaginationContainer_query_3e6UuE
}
fragment LikedPhotosPaginationContainer_query_3e6UuE on Query {
likedPhotos(first: $pageSize, after: $cursor) {
edges {
node {
id
legacyId
canonicalPath
name
description
category
uploadedAt
location
width
height
isLikedByMe
notSafeForWork
tags
photographer: uploader {
id
legacyId
username
displayName
canonicalPath
avatar {
images {
url
id
}
id
}
followedByUsers {
totalCount
isFollowedByMe
}
}
images(sizes: [33, 35]) {
size
url
jpegUrl
webpUrl
id
}
__typename
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
"""

View File

View File

@@ -0,0 +1,416 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
GetProfileProjects = """\
query GetProfileProjects($username: String, $after: String) {
user(username: $username) {
profileProjects(first: 12, after: $after) {
pageInfo {
endCursor
hasNextPage
}
nodes {
__typename
adminFlags {
mature_lock
privacy_lock
dmca_lock
flagged_lock
privacy_violation_lock
trademark_lock
spam_lock
eu_ip_lock
}
colors {
r
g
b
}
covers {
size_202 {
url
}
size_404 {
url
}
size_808 {
url
}
}
features {
url
name
featuredOn
ribbon {
image
image2x
image3x
}
}
fields {
id
label
slug
url
}
hasMatureContent
id
isFeatured
isHiddenFromWorkTab
isMatureReviewSubmitted
isOwner
isFounder
isPinnedToSubscriptionOverview
isPrivate
linkedAssets {
...sourceLinkFields
}
linkedAssetsCount
sourceFiles {
...sourceFileFields
}
matureAccess
modifiedOn
name
owners {
...OwnerFields
images {
size_50 {
url
}
}
}
premium
publishedOn
stats {
appreciations {
all
}
views {
all
}
comments {
all
}
}
slug
tools {
id
title
category
categoryLabel
categoryId
approved
url
backgroundColor
}
url
}
}
}
}
fragment sourceFileFields on SourceFile {
__typename
sourceFileId
projectId
userId
title
assetId
renditionUrl
mimeType
size
category
licenseType
unitAmount
currency
tier
hidden
extension
hasUserPurchased
}
fragment sourceLinkFields on LinkedAsset {
__typename
name
premium
url
category
licenseType
}
fragment OwnerFields on User {
displayName
hasPremiumAccess
id
isFollowing
isProfileOwner
location
locationUrl
url
username
availabilityInfo {
availabilityTimeline
isAvailableFullTime
isAvailableFreelance
}
}
"""
GetMoodboardItemsAndRecommendations = """\
query GetMoodboardItemsAndRecommendations(
$id: Int!
$firstItem: Int!
$afterItem: String
$shouldGetRecommendations: Boolean!
$shouldGetItems: Boolean!
$shouldGetMoodboardFields: Boolean!
) {
viewer @include(if: $shouldGetMoodboardFields) {
isOptedOutOfRecommendations
isAdmin
}
moodboard(id: $id) {
...moodboardFields @include(if: $shouldGetMoodboardFields)
items(first: $firstItem, after: $afterItem) @include(if: $shouldGetItems) {
pageInfo {
endCursor
hasNextPage
}
nodes {
...nodesFields
}
}
recommendedItems(first: 80) @include(if: $shouldGetRecommendations) {
nodes {
...nodesFields
fetchSource
}
}
}
}
fragment moodboardFields on Moodboard {
id
label
privacy
followerCount
isFollowing
projectCount
url
isOwner
owners {
...OwnerFields
images {
size_50 {
url
}
size_100 {
url
}
size_115 {
url
}
size_230 {
url
}
size_138 {
url
}
size_276 {
url
}
}
}
}
fragment projectFields on Project {
__typename
id
isOwner
publishedOn
matureAccess
hasMatureContent
modifiedOn
name
url
isPrivate
slug
license {
license
description
id
label
url
text
images
}
fields {
label
}
colors {
r
g
b
}
owners {
...OwnerFields
images {
size_50 {
url
}
size_100 {
url
}
size_115 {
url
}
size_230 {
url
}
size_138 {
url
}
size_276 {
url
}
}
}
covers {
size_original {
url
}
size_max_808 {
url
}
size_808 {
url
}
size_404 {
url
}
size_202 {
url
}
size_230 {
url
}
size_115 {
url
}
}
stats {
views {
all
}
appreciations {
all
}
comments {
all
}
}
}
fragment exifDataValueFields on exifDataValue {
id
label
value
searchValue
}
fragment nodesFields on MoodboardItem {
id
entityType
width
height
flexWidth
flexHeight
images {
size
url
}
entity {
... on Project {
...projectFields
}
... on ImageModule {
project {
...projectFields
}
colors {
r
g
b
}
exifData {
lens {
...exifDataValueFields
}
software {
...exifDataValueFields
}
makeAndModel {
...exifDataValueFields
}
focalLength {
...exifDataValueFields
}
iso {
...exifDataValueFields
}
location {
...exifDataValueFields
}
flash {
...exifDataValueFields
}
exposureMode {
...exifDataValueFields
}
shutterSpeed {
...exifDataValueFields
}
aperture {
...exifDataValueFields
}
}
}
... on MediaCollectionComponent {
project {
...projectFields
}
}
}
}
fragment OwnerFields on User {
displayName
hasPremiumAccess
id
isFollowing
isProfileOwner
location
locationUrl
url
username
availabilityInfo {
availabilityTimeline
isAvailableFullTime
isAvailableFreelance
}
}
"""

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
SHADOW = """
<span class="shadow">
<img src="{src}" class="smshadow" width="{width}" height="{height}">
</span>
<br><br>
"""
HEADER = """<div usr class="gr">
<div class="metadata">
<h2><a href="{url}">{title}</a></h2>
<ul>
<li class="author">
by <span class="name"><span class="username-with-symbol u">
<a class="u regular username" href="{userurl}">{username}</a>\
<span class="user-symbol regular"></span></span></span>,
<span>{date}</span>
</li>
</ul>
</div>
"""
HEADER_CUSTOM = """<div class='boxtop journaltop'>
<h2>
<img src="https://st.deviantart.net/minish/gruzecontrol/icons/journal.gif\
?2" style="vertical-align:middle" alt=""/>
<a href="{url}">{title}</a>
</h2>
Journal Entry: <span>{date}</span>
"""
HTML = """text:<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{title}</title>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/deviantart-network_lc.css?3843780832"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/group_secrets_lc.css?3250492874"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/v6core_lc.css?4246581581"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/sidebar_lc.css?1490570941"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/writer_lc.css?3090682151"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/css/v6loggedin_lc.css?3001430805"/>
<style>{css}</style>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/core.css?1488405371919"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/peeky.css?1487067424177"/>
<link rel="stylesheet" href="https://st.deviantart.net\
/roses/cssmin/desktop.css?1491362542749"/>
<link rel="stylesheet" href="https://static.parastorage.com/services\
/da-deviation/2bfd1ff7a9d6bf10d27b98dd8504c0399c3f9974a015785114b7dc6b\
/app.min.css"/>
</head>
<body id="deviantART-v7" class="bubble no-apps loggedout w960 deviantart">
<div id="output">
<div class="dev-page-container bubbleview">
<div class="dev-page-view view-mode-normal">
<div class="dev-view-main-content">
<div class="dev-view-deviation">
{shadow}
<div class="journal-wrapper tt-a">
<div class="journal-wrapper2">
<div class="journal {cls} journalcontrol">
{html}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
"""
HTML_EXTRA = """\
<div id="devskin0"><div class="negate-box-margin" style="">\
<div usr class="gr-box gr-genericbox"
><i usr class="gr1"><i></i></i
><i usr class="gr2"><i></i></i
><i usr class="gr3"><i></i></i
><div usr class="gr-top">
<i usr class="tri"></i>
{}
</div>
</div><div usr class="gr-body"><div usr class="gr">
<div class="grf-indent">
<div class="text">
{} </div>
</div>
</div></div>
<i usr class="gr3 gb"></i>
<i usr class="gr2 gb"></i>
<i usr class="gr1 gb gb1"></i> </div>
</div></div>"""
TEXT = """text:{title}
by {username}, {date}
{content}
"""

View File

@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
from ... import text, util
from .. deviantart import eclipse_media
def to_html(markup):
html = []
html.append('<div data-editor-viewer="1" '
'class="_83r8m _2CKTq _3NjDa mDnFl">')
data = util.json_loads(markup)
for block in data["document"]["content"]:
process_content(html, block)
html.append("</div>")
return "".join(html)
def process_content(html, content):
type = content["type"]
if type == "paragraph":
if children := content.get("content"):
html.append('<p style="')
if attrs := content.get("attrs"):
if align := attrs.get("textAlign"):
html.append("text-align:")
html.append(align)
html.append(";")
process_indentation(html, attrs)
html.append('">')
else:
html.append('margin-inline-start:0px">')
for block in children:
process_content(html, block)
html.append("</p>")
else:
html.append('<p class="empty-p"><br/></p>')
elif type == "text":
process_text(html, content)
elif type == "heading":
attrs = content["attrs"]
level = str(attrs.get("level") or "3")
html.append("<h")
html.append(level)
html.append(' style="text-align:')
html.append(attrs.get("textAlign") or "left")
html.append('">')
html.append('<span style="')
process_indentation(html, attrs)
html.append('">')
process_children(html, content)
html.append("</span></h")
html.append(level)
html.append(">")
elif type in ("listItem", "bulletList", "orderedList", "blockquote"):
c = type[1]
tag = (
"li" if c == "i" else
"ul" if c == "u" else
"ol" if c == "r" else
"blockquote"
)
html.append("<" + tag + ">")
process_children(html, content)
html.append("</" + tag + ">")
elif type == "anchor":
attrs = content["attrs"]
html.append('<a id="')
html.append(attrs.get("id") or "")
html.append('" data-testid="anchor"></a>')
elif type == "hardBreak":
html.append("<br/><br/>")
elif type == "horizontalRule":
html.append("<hr/>")
elif type == "da-deviation":
process_deviation(html, content)
elif type == "da-mention":
user = content["attrs"]["user"]["username"]
html.append('<a href="https://www.deviantart.com/')
html.append(user.lower())
html.append('" data-da-type="da-mention" data-user="">@<!-- -->')
html.append(user)
html.append('</a>')
elif type == "da-gif":
attrs = content["attrs"]
width = str(attrs.get("width") or "")
height = str(attrs.get("height") or "")
url = text.escape(attrs.get("url") or "")
html.append('<div data-da-type="da-gif" data-width="')
html.append(width)
html.append('" data-height="')
html.append(height)
html.append('" data-alignment="')
html.append(attrs.get("alignment") or "")
html.append('" data-url="')
html.append(url)
html.append('" class="t61qu"><video role="img" autoPlay="" '
'muted="" loop="" style="pointer-events:none" '
'controlsList="nofullscreen" playsInline="" '
'aria-label="gif" data-da-type="da-gif" width="')
html.append(width)
html.append('" height="')
html.append(height)
html.append('" src="')
html.append(url)
html.append('" class="_1Fkk6"></video></div>')
elif type == "da-video":
src = text.escape(content["attrs"].get("src") or "")
html.append('<div data-testid="video" data-da-type="da-video" '
'data-src="')
html.append(src)
html.append('" class="_1Uxvs"><div data-canfs="yes" data-testid="v'
'ideo-inner" class="main-video" style="width:780px;hei'
'ght:438px"><div style="width:780px;height:438px">'
'<video src="')
html.append(src)
html.append('" style="width:100%;height:100%;" preload="auto" cont'
'rols=""></video></div></div></div>')
else:
import logging
logging.getLogger("tiptap").warning(
"Unsupported content type '%s'", type)
def process_text(html, content):
if marks := content.get("marks"):
close = []
for mark in marks:
type = mark["type"]
if type == "link":
attrs = mark.get("attrs") or {}
html.append('<a href="')
html.append(text.escape(attrs.get("href") or ""))
if "target" in attrs:
html.append('" target="')
html.append(attrs["target"])
html.append('" rel="')
html.append(attrs.get("rel") or
"noopener noreferrer nofollow ugc")
html.append('">')
close.append("</a>")
elif type == "bold":
html.append("<strong>")
close.append("</strong>")
elif type == "italic":
html.append("<em>")
close.append("</em>")
elif type == "underline":
html.append("<u>")
close.append("</u>")
elif type == "strike":
html.append("<s>")
close.append("</s>")
elif type == "textStyle" and len(mark) <= 1:
pass
else:
import logging
logging.getLogger("tiptap").warning(
"Unsupported text marker '%s'", type)
close.reverse()
html.append(text.escape(content["text"]))
html.extend(close)
else:
html.append(text.escape(content["text"]))
def process_children(html, content):
if children := content.get("content"):
for block in children:
process_content(html, block)
def process_indentation(html, attrs):
itype = ("text-indent" if attrs.get("indentType") == "line" else
"margin-inline-start")
isize = str((attrs.get("indentation") or 0) * 24)
html.append(itype + ":" + isize + "px")
def process_deviation(html, content):
dev = content["attrs"]["deviation"]
media = dev.get("media") or ()
html.append('<div class="jjNX2">')
html.append('<figure class="Qf-HY" data-da-type="da-deviation" '
'data-deviation="" '
'data-width="" data-link="" data-alignment="center">')
if "baseUri" in media:
url, formats = eclipse_media(media)
full = formats["fullview"]
html.append('<a href="')
html.append(text.escape(dev["url"]))
html.append('" class="_3ouD5" style="margin:0 auto;display:flex;'
'align-items:center;justify-content:center;'
'overflow:hidden;width:780px;height:')
html.append(str(780 * full["h"] / full["w"]))
html.append('px">')
html.append('<img src="')
html.append(text.escape(url))
html.append('" alt="')
html.append(text.escape(dev["title"]))
html.append('" style="width:100%;max-width:100%;display:block"/>')
html.append("</a>")
elif "textContent" in dev:
html.append('<div class="_32Hs4" style="width:350px">')
html.append('<a href="')
html.append(text.escape(dev["url"]))
html.append('" class="_3ouD5">')
html.append('''\
<section class="Q91qI aG7Yi" style="width:350px;height:313px">\
<div class="_16ECM _1xMkk" aria-hidden="true">\
<svg height="100%" viewBox="0 0 15 12" preserveAspectRatio="xMidYMin slice" \
fill-rule="evenodd">\
<linearGradient x1="87.8481761%" y1="16.3690766%" \
x2="45.4107524%" y2="71.4898596%" id="app-root-3">\
<stop stop-color="#00FF62" offset="0%"></stop>\
<stop stop-color="#3197EF" stop-opacity="0" offset="100%"></stop>\
</linearGradient>\
<text class="_2uqbc" fill="url(#app-root-3)" text-anchor="end" x="15" y="11">J\
</text></svg></div><div class="_1xz9u">Literature</div><h3 class="_2WvKD">\
''')
html.append(text.escape(dev["title"]))
html.append('</h3><div class="_2CPLm">')
html.append(text.escape(dev["textContent"]["excerpt"]))
html.append('</div></section></a></div>')
html.append('</figure></div>')

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
def parse(data):
"""Parse JSURL data
Nested lists and dicts are handled in a special way to deal
with the way Tsumino expects its parameters -> expand(...)
Example: ~(name~'John*20Doe~age~42~children~(~'Mary~'Bill))
Ref: https://github.com/Sage/jsurl
"""
i = 0
imax = len(data)
def eat(expected):
nonlocal i
if data[i] != expected:
raise ValueError(
f"bad JSURL syntax: expected '{expected}', got {data[i]}")
i += 1
def decode():
nonlocal i
beg = i
result = ""
while i < imax:
ch = data[i]
if ch not in "~)*!":
i += 1
elif ch == "*":
if beg < i:
result += data[beg:i]
if data[i + 1] == "*":
result += chr(int(data[i+2:i+6], 16))
i += 6
else:
result += chr(int(data[i+1:i+3], 16))
i += 3
beg = i
elif ch == "!":
if beg < i:
result += data[beg:i]
result += "$"
i += 1
beg = i
else:
break
return result + data[beg:i]
def parse_one():
nonlocal i
eat('~')
result = ""
ch = data[i]
if ch == "(":
i += 1
if data[i] == "~":
result = []
if data[i+1] == ")":
i += 1
else:
result.append(parse_one())
while data[i] == "~":
result.append(parse_one())
else:
result = {}
if data[i] != ")":
while True:
key = decode()
value = parse_one()
for ekey, evalue in expand(key, value):
result[ekey] = evalue
if data[i] != "~":
break
i += 1
eat(")")
elif ch == "'":
i += 1
result = decode()
else:
beg = i
i += 1
while i < imax and data[i] not in "~)":
i += 1
sub = data[beg:i]
if ch in "0123456789-":
fval = float(sub)
ival = int(fval)
result = ival if ival == fval else fval
else:
if sub not in ("true", "false", "null"):
raise ValueError("bad value keyword: " + sub)
result = sub
return result
def expand(key, value):
if isinstance(value, list):
for index, cvalue in enumerate(value):
ckey = f"{key}[{index}]"
yield from expand(ckey, cvalue)
elif isinstance(value, dict):
for ckey, cvalue in value.items():
ckey = f"{key}[{ckey}]"
yield from expand(ckey, cvalue)
else:
yield key, value
return parse_one()

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
AlbumGet = """
query AlbumGet($id: ID!) {
album {
get(id: $id) {
... on Album {
...AlbumStandard
}
... on MutationError {
errors {
code
message
}
}
}
}
}
fragment AlbumStandard on Album {
__typename
id
title
labels
description
created
modified
like_status
number_of_favorites
rating
status
marked_for_deletion
marked_for_processing
number_of_pictures
number_of_animated_pictures
slug
is_manga
url
download_url
permissions
cover {
width
height
size
url
}
created_by {
id
name
display_name
user_title
avatar {
url
size
}
url
}
content {
id
title
url
}
language {
id
title
url
}
tags {
id
category
text
url
count
}
genres {
id
title
slug
url
}
audiences {
id
title
url
url
}
last_viewed_picture {
id
position
url
}
}
"""
AlbumListOwnPictures = """
query AlbumListOwnPictures($input: PictureListInput!) {
picture {
list(input: $input) {
info {
...FacetCollectionInfo
}
items {
...PictureStandardWithoutAlbum
}
}
}
}
fragment FacetCollectionInfo on FacetCollectionInfo {
page
has_next_page
has_previous_page
total_items
total_pages
items_per_page
url_complete
url_filters_only
}
fragment PictureStandardWithoutAlbum on Picture {
__typename
id
title
created
like_status
number_of_comments
number_of_favorites
status
width
height
resolution
aspect_ratio
url_to_original
url_to_video
is_animated
position
tags {
id
category
text
url
}
permissions
url
thumbnails {
width
height
size
url
}
}
"""
AlbumListWithPeek = """
query AlbumListWithPeek($input: AlbumListInput!) {
album {
list(input: $input) {
info {
...FacetCollectionInfo
}
items {
...AlbumMinimal
peek_thumbnails {
width
height
size
url
}
}
}
}
}
fragment FacetCollectionInfo on FacetCollectionInfo {
page
has_next_page
has_previous_page
total_items
total_pages
items_per_page
url_complete
url_filters_only
}
fragment AlbumMinimal on Album {
__typename
id
title
labels
description
created
modified
number_of_favorites
number_of_pictures
slug
is_manga
url
download_url
cover {
width
height
size
url
}
content {
id
title
url
}
language {
id
title
url
}
tags {
id
category
text
url
count
}
genres {
id
title
slug
url
}
audiences {
id
title
url
}
}
"""

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
Get_comicChapterList = """
query Get_comicChapterList($comicId: ID!) {
get_comicChapterList(comicId: $comicId) {
data {
id
dname
title
lang
urlPath
srcTitle
sourceId
dateCreate
}
}
}
"""
Get_chapterNode = """
query Get_chapterNode($getChapterNodeId: ID!) {
get_chapterNode(id: $getChapterNodeId) {
data {
id
dname
lang
sourceId
srcTitle
dateCreate
comicNode{
id
}
imageFile {
urlList
}
}
}
}
"""
Get_comicNode = """
query Get_comicNode($getComicNodeId: ID!) {
get_comicNode(id: $getComicNodeId) {
data {
id
name
artists
authors
genres
}
}
}
"""
get_content_source_chapterList = """
query get_content_source_chapterList($sourceId: Int!) {
get_content_source_chapterList(
sourceId: $sourceId
) {
id
data {
id
sourceId
dbStatus
isNormal
isHidden
isDeleted
isFinal
dateCreate
datePublic
dateModify
lang
volume
serial
dname
title
urlPath
srcTitle srcColor
count_images
stat_count_post_child
stat_count_post_reply
stat_count_views_login
stat_count_views_guest
userId
userNode {
id
data {
id
name
uniq
avatarUrl
urlPath
verified
deleted
banned
dateCreate
dateOnline
stat_count_chapters_normal
stat_count_chapters_others
is_adm is_mod is_vip is_upr
}
}
disqusId
}
}
}
"""
get_content_comic_sources = """
query get_content_comic_sources($comicId: Int!, $dbStatuss: [String] = [], $userId: Int, $haveChapter: Boolean, $sortFor: String) {
get_content_comic_sources(
comicId: $comicId
dbStatuss: $dbStatuss
userId: $userId
haveChapter: $haveChapter
sortFor: $sortFor
) {
id
data{
id
dbStatus
isNormal
isHidden
isDeleted
lang name altNames authors artists
release
genres summary{code} extraInfo{code}
urlCover600
urlCover300
urlCoverOri
srcTitle srcColor
chapterCount
chapterNode_last {
id
data {
dateCreate datePublic dateModify
volume serial
dname title
urlPath
userNode {
id data {uniq name}
}
}
}
}
}
}
"""

View File

@@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
# Copyright 2026 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.
SubredditPostQuery = """\
query SubredditPostQuery(
$url: String!
) {
getPost(
data: { url: $url }
) {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource redgifsSource
ownerAvatar username displayName favoriteCount isPaid tags
commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
"""
SubredditQuery = """\
query SubredditQuery(
$url: String!
$iterator: String
$sortBy: GallerySortBy
$filter: GalleryFilter
$limit: Int!
) {
getSubreddit(
data: {
url: $url,
iterator: $iterator,
filter: $filter,
limit: $limit,
sortBy: $sortBy
}
) {
__typename id url title secondaryTitle description createdAt isNsfw
subscribers isComplete itemCount videoCount pictureCount albumCount
isPaid username tags isFollowing
banner { url width height isOptimized }
children {
iterator items {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource
redgifsSource ownerAvatar username displayName favoriteCount
isPaid tags commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
}
}
"""
SubredditChildrenQuery = """\
query SubredditChildrenQuery(
$subredditId: Int!
$iterator: String
$filter: GalleryFilter
$sortBy: GallerySortBy
$limit: Int!
$isNsfw: Boolean
) {
getSubredditChildren(
data: {
subredditId: $subredditId,
iterator: $iterator,
filter: $filter,
sortBy: $sortBy,
limit: $limit,
isNsfw: $isNsfw
},
) {
iterator items {
__typename id url title subredditId subredditTitle subredditUrl
redditPath isNsfw hasAudio fullLengthSource gfycatSource
redgifsSource ownerAvatar username displayName favoriteCount isPaid
tags commentsCount commentsRepliesCount isFavorite
albumContent { mediaSources { url width height isOptimized } }
mediaSources { url width height isOptimized }
blurredMediaSources { url width height isOptimized }
}
}
}
"""
GetFollowingSubreddits = """\
query GetFollowingSubreddits(
$iterator: String,
$limit: Int!,
$filter: GalleryFilter,
$isNsfw: Boolean,
$sortBy: GallerySortBy
) {
getFollowingSubreddits(
data: {
isNsfw: $isNsfw
limit: $limit
filter: $filter
iterator: $iterator
sortBy: $sortBy
}
) {
iterator items {
__typename id url title secondaryTitle description createdAt isNsfw
subscribers isComplete itemCount videoCount pictureCount albumCount
isFollowing
}
}
}
"""
LoginQuery = """\
query LoginQuery(
$username: String!,
$password: String!
) {
login(
username: $username,
password: $password
) {
username token expiresAt isAdmin status isPremium
}
}
"""
ItemTypeQuery = """\
query ItemTypeQuery(
$url: String!
) {
getItemType(
url: $url
)
}
"""

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2025 Mike Fährmann
# Copyright 2025-2026 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
@@ -22,8 +22,8 @@ import random
import hashlib
import binascii
import itertools
from . import text, util
from .cache import cache
from ... import text, util
from ...cache import cache
class ClientTransaction():

View File

@@ -1,11 +1,20 @@
# -*- coding: utf-8 -*-
from gallery_dl import extractor, downloader, postprocessor
import os
hiddenimports = [
package.__name__ + "." + module
f"{package.__name__}.{module}"
for package in (extractor, downloader, postprocessor)
for module in package.modules
]
base = extractor.__name__ + ".utils."
path = os.path.join(extractor.__path__[0], "utils")
hiddenimports.extend(
base + file[:-3]
for file in os.listdir(path)
if not file.startswith("__")
)
hiddenimports.append("yt_dlp")

View File

@@ -3,6 +3,6 @@ exclude = .git,__pycache__,build,dist,archive
ignore = E203,E226,W504
per-file-ignores =
setup.py: E501
gallery_dl/extractor/500px.py: E501
gallery_dl/extractor/mangapark.py: E501
gallery_dl/extractor/utils/500px_graphql.py: E501
gallery_dl/extractor/utils/mangapark_graphql.py: E501
test/results/*.py: E122,E241,E402,E501

View File

@@ -44,6 +44,7 @@ FILES = [
PACKAGES = [
"gallery_dl",
"gallery_dl.extractor",
"gallery_dl.extractor.utils",
"gallery_dl.downloader",
"gallery_dl.postprocessor",
]