11import concurrent .futures
22import re
33import webbrowser
4+ from datetime import datetime
45from pathlib import Path
56from urllib .parse import urljoin
67
1415HTML_POSTLIST = "https://fantia.jp/fanclubs/{}/posts?page={}"
1516DEFAULT_UA = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36'
1617
18+ # Default templates for directory and filename formatting
19+ # Directory template variables:
20+ # {fanclub_id}, {fanclub_name}, {fanclub_full_name}, {creator_name}
21+ # {post_id}, {post_title}, {post_date}, {rating}
22+ # Filename template variables (in addition to directory ones):
23+ # {content_id}, {idx}, {stem_cleaned}
24+ # Note: extension (.ext) is always appended automatically and not part of the template
25+ DEFAULT_DIR_TEMPLATE = '{fanclub_full_name} ({fanclub_id})/{post_id} {post_title}'
26+ DEFAULT_FILENAME_TEMPLATE = '{content_id}{idx} {stem_cleaned}'
27+
28+
1729class FantiaDownloader :
18- def __init__ (self , key , fanclub = None , output = '.' , subfolder_template = None , skip_existing = True , quick_stop = True ):
30+ def __init__ (self , key , fanclub = None , output = '.' , dir_template = None , filename_template = None , skip_existing = True , quick_stop = True ):
1931 super ().__init__ ()
2032 self .key = key
2133 self .fanclub = fanclub
2234 if not self .fanclub :
2335 print ('[W] no fanclub id is given. "downloadAll()" won\' t work.' )
2436 self .output = Path (output )
25- self .subfolder_template = subfolder_template or '{fanclub_full_name} ({fanclub_id})'
26- self .subfolder_name = None
27- #TODO: self.filename_template = '{id} {title}'
37+ self .dir_template = dir_template or DEFAULT_DIR_TEMPLATE
38+ self .filename_template = filename_template or DEFAULT_FILENAME_TEMPLATE
2839 self .skip_existing = skip_existing
2940 self .quick_stop = skip_existing and quick_stop
3041 self .fanclub_info = None
@@ -56,13 +67,30 @@ def download_all(self):
5667 with self .fetch (API_FANCLUB .format (self .fanclub )) as r :
5768 self .update_fanclub_info (r .json ()['fanclub' ])
5869
59- output_full = self .output / self .subfolder_name
60- print (f'Fanclub { self .fanclub } : download all to { output_full } .' )
61- if output_full .exists ():
62- existing_ids = [m [1 ] for f in output_full .iterdir () if (m := re .match (r'(\d+) *' , f .name ))]
70+ # Get base output dir (fanclub level) for checking existing downloads
71+ # Extract only the fanclub-level part of the template (before any post-level placeholders)
72+ fanclub_subs = self ._get_fanclub_substitutes ()
73+ # Split dir_template and only format the parts that don't require post info
74+ template_parts = self .dir_template .split ('/' )
75+ base_parts = []
76+ for part in template_parts :
77+ if any (p in part for p in ['{post_id}' , '{post_title}' , '{post_date}' , '{rating}' ]):
78+ break
79+ base_parts .append (part .format (** fanclub_subs ))
80+ base_dir = self .output / '/' .join (base_parts ) if base_parts else self .output
81+
82+ print (f'Fanclub { self .fanclub } : download all to { base_dir } .' )
83+ existing_ids = []
84+ if base_dir .exists ():
85+ # Check for subdirectories starting with post_id (new structure)
86+ for f in base_dir .iterdir ():
87+ if f .is_dir () and (m := re .match (r'^(\d+)\b' , f .name )):
88+ existing_ids .append (m .group (1 ))
89+ # Also check for files starting with post_id (old flat structure)
90+ for f in base_dir .iterdir ():
91+ if f .is_file () and (m := re .match (r'^(\d+)\b' , f .name )):
92+ existing_ids .append (m .group (1 ))
6393 existing_ids = list (dict .fromkeys (existing_ids ))
64- else :
65- existing_ids = []
6694 print (f'{ len (existing_ids )} ID(s) have already been downloaded.' )
6795
6896 def fetch_all ():
@@ -110,15 +138,53 @@ def fetch_gallery_page(self, page):
110138
111139 def update_fanclub_info (self , d ):
112140 self .fanclub_info = d
113- substitutes = dict (
114- fanclub_full_name = self .fanclub_info ['fanclub_name_with_creator_name' ],
115- fanclub_name = self .fanclub_info ['fanclub_name' ],
116- creater_name = self .fanclub_info ['creator_name' ],
141+
142+ def _get_fanclub_substitutes (self ):
143+ """Get template substitutes from fanclub info."""
144+ if not self .fanclub_info :
145+ return {}
146+ return dict (
147+ fanclub_full_name = safeify (str (self .fanclub_info ['fanclub_name_with_creator_name' ])),
148+ fanclub_name = safeify (str (self .fanclub_info ['fanclub_name' ])),
149+ creator_name = safeify (str (self .fanclub_info ['creator_name' ])),
117150 fanclub_id = self .fanclub_info ['id' ]
118151 )
119- substitutes = {k : safeify (str (v )) for k , v in substitutes .items ()}
120- if not self .subfolder_name :
121- self .subfolder_name = self .subfolder_template .format (** substitutes )
152+
153+ def _get_post_substitutes (self , post_data ):
154+ """Get template substitutes from post data."""
155+ # Parse posted_at date (format: "Fri, 16 Aug 2024 07:15:36 +0900")
156+ post_date = ''
157+ if posted_at := post_data .get ('posted_at' ):
158+ try :
159+ dt = datetime .strptime (posted_at , "%a, %d %b %Y %H:%M:%S %z" )
160+ post_date = dt .strftime ('%Y%m%d' )
161+ except ValueError :
162+ post_date = ''
163+
164+ return dict (
165+ post_id = post_data .get ('id' , '' ),
166+ post_title = safeify (str (post_data .get ('title' , '' ))),
167+ post_date = post_date ,
168+ rating = post_data .get ('rating' , '' )
169+ )
170+
171+ def _format_dir (self , post_substitutes ):
172+ """Format directory path using template and substitutes."""
173+ subs = {** self ._get_fanclub_substitutes (), ** post_substitutes }
174+ return self .dir_template .format (** subs )
175+
176+ def _format_filename (self , post_substitutes , content_id , idx_string , stem_cleaned , ext ):
177+ """Format filename using template and substitutes."""
178+ subs = {
179+ ** self ._get_fanclub_substitutes (),
180+ ** post_substitutes ,
181+ 'content_id' : content_id ,
182+ 'idx' : idx_string ,
183+ 'stem_cleaned' : stem_cleaned
184+ }
185+ # Format the template, strip to remove trailing spaces, then append extension
186+ filename_without_ext = self .filename_template .format (** subs ).strip ()
187+ return f'{ filename_without_ext } .{ ext } '
122188
123189 def get_post_photos (self , id ):
124190 print (f'Fetching post { id } ...' )
@@ -139,27 +205,37 @@ def get_post_photos(self, id):
139205 if not self .fanclub_info :
140206 self .update_fanclub_info (d ['post' ]['fanclub' ])
141207
208+ post_data = d ['post' ]
209+ post_subs = self ._get_post_substitutes (post_data )
210+ output_dir = self .output / self ._format_dir (post_subs )
211+ # Ensure multi-level directory exists
212+ output_dir .mkdir (parents = True , exist_ok = True )
213+
142214 with concurrent .futures .ThreadPoolExecutor (max_workers = 3 ) as ex :
143- if thumb := d [ 'post' ] .get ('thumb' , None ):
215+ if thumb := post_data .get ('thumb' , None ):
144216 img_url = thumb ['original' ]
145217 stem , ext = get_webname (img_url ).rsplit ('.' , 1 )
146- ex .submit (download , img_url , filename = self .output / self .subfolder_name / f'{ id } !cover.{ ext } ' , verbose = 1 )
147- if post_contents := d ['post' ].get ('post_contents' , None ):
218+ cover_filename = f'!cover.{ ext } '
219+ ex .submit (download , img_url , filename = output_dir / cover_filename , verbose = 1 )
220+ if post_contents := post_data .get ('post_contents' , None ):
148221 for c in post_contents :
149222 cid = c ['id' ]
150223 if photos := c .get ('post_content_photos' , None ):
151224 for idx , p in enumerate (photos , 1 ):
152225 img_url = p ['url' ]['original' ]
153226 stem , ext = get_webname (img_url ).rsplit ('.' , 1 )
154227 # Clean up the filename; remove all the UUID-ish garbage
155- stem = re .sub (r'^[0-9a-fA-F]{8}_(.+)$' , r'\1' , stem )
156- stem = re .sub (r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' , '' , stem )
228+ stem_cleaned = re .sub (r'^[0-9a-fA-F]{8}_(.+)$' , r'\1' , stem )
229+ stem_cleaned = re .sub (r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' , '' , stem_cleaned )
230+ stem_cleaned = stem_cleaned .strip ()
157231 idx_string = '_' + str (idx ).zfill (len (str (len (photos )))) if len (photos ) > 1 else ''
158- stem = f' { id } { cid } { idx_string } { stem } ' . strip ( )
159- ex .submit (download , img_url , filename = self . output / self . subfolder_name / f' { stem } . { ext } ' , verbose = 1 )
232+ filename = self . _format_filename ( post_subs , cid , idx_string , stem_cleaned , ext )
233+ ex .submit (download , img_url , filename = output_dir / filename , verbose = 1 )
160234 if 'download_uri' in c :
161235 dl_url = urljoin ('https://fantia.jp' , c ['download_uri' ])
162- ex .submit (download , dl_url , filename = self .output / self .subfolder_name / f'{ id } { cid } { c ["filename" ]} ' , session = self .session , verbose = 1 )
236+ # For download files, use original filename with content_id prefix
237+ dl_filename = f'{ cid } { c ["filename" ]} '
238+ ex .submit (download , dl_url , filename = output_dir / dl_filename , session = self .session , verbose = 1 )
163239
164240if __name__ == "__main__" :
165241 pass
0 commit comments