11import copy
2+ import ipaddress
23import re
4+ import socket
35import traceback
46from functools import reduce
57from typing import List , Set
1315
1416requests .packages .urllib3 .disable_warnings ()
1517
18+ _BLOCKED_NETWORKS = [
19+ ipaddress .ip_network ("0.0.0.0/8" ),
20+ ipaddress .ip_network ("10.0.0.0/8" ),
21+ ipaddress .ip_network ("100.64.0.0/10" ),
22+ ipaddress .ip_network ("127.0.0.0/8" ),
23+ ipaddress .ip_network ("169.254.0.0/16" ),
24+ ipaddress .ip_network ("172.16.0.0/12" ),
25+ ipaddress .ip_network ("192.168.0.0/16" ),
26+ ipaddress .ip_network ("198.18.0.0/15" ),
27+ ipaddress .ip_network ("198.51.100.0/24" ),
28+ ipaddress .ip_network ("203.0.113.0/24" ),
29+ ipaddress .ip_network ("240.0.0.0/4" ),
30+ ipaddress .ip_network ("::1/128" ),
31+ ipaddress .ip_network ("fc00::/7" ),
32+ ipaddress .ip_network ("fe80::/10" ),
33+ ]
34+
35+
36+ def _validate_url (url : str ) -> None :
37+ parsed = urlparse (url )
38+ if parsed .scheme not in ("http" , "https" ):
39+ raise ValueError (f"Disallowed URL scheme: { parsed .scheme !r} " )
40+ hostname = parsed .hostname
41+ if not hostname :
42+ raise ValueError ("Missing hostname in URL" )
43+ try :
44+ addr_infos = socket .getaddrinfo (hostname , None )
45+ except socket .gaierror as exc :
46+ raise ValueError (f"Cannot resolve host { hostname !r} : { exc } " ) from exc
47+ for addr_info in addr_infos :
48+ ip_str = addr_info [4 ][0 ]
49+ try :
50+ ip = ipaddress .ip_address (ip_str )
51+ except ValueError :
52+ continue
53+ if any (ip in net for net in _BLOCKED_NETWORKS ):
54+ raise ValueError (f"URL resolves to a blocked internal address: { ip } " )
55+
1656
1757class ChildLink :
1858 def __init__ (self , url , tag ):
@@ -29,27 +69,34 @@ def fork(self, level: int, exclude_link_url: Set[str], fork_handler):
2969 self .fork_child (ChildLink (self .base_url , None ), self .selector_list , level , exclude_link_url , fork_handler )
3070
3171 @staticmethod
32- def fork_child (child_link : ChildLink , selector_list : List [str ], level : int , exclude_link_url : Set [str ],
33- fork_handler ):
72+ def fork_child (
73+ child_link : ChildLink , selector_list : List [str ], level : int , exclude_link_url : Set [str ], fork_handler
74+ ):
3475 if level < 0 :
3576 return
3677 else :
3778 child_link .url = remove_fragment (child_link .url )
38- child_url = child_link .url [:- 1 ] if child_link .url .endswith ('/' ) else child_link .url
79+ child_url = child_link .url [:- 1 ] if child_link .url .endswith ("/" ) else child_link .url
3980 if not exclude_link_url .__contains__ (child_url ):
4081 exclude_link_url .add (child_url )
4182 response = Fork (child_link .url , selector_list ).fork ()
4283 fork_handler (child_link , response )
4384 for child_link in response .child_link_list :
44- child_url = child_link .url [:- 1 ] if child_link .url .endswith ('/' ) else child_link .url
85+ child_url = child_link .url [:- 1 ] if child_link .url .endswith ("/" ) else child_link .url
4586 if not exclude_link_url .__contains__ (child_url ):
4687 ForkManage .fork_child (child_link , selector_list , level - 1 , exclude_link_url , fork_handler )
4788
4889
4990def remove_fragment (url : str ) -> str :
5091 parsed_url = urlparse (url )
51- modified_url = ParseResult (scheme = parsed_url .scheme , netloc = parsed_url .netloc , path = parsed_url .path ,
52- params = parsed_url .params , query = parsed_url .query , fragment = None )
92+ modified_url = ParseResult (
93+ scheme = parsed_url .scheme ,
94+ netloc = parsed_url .netloc ,
95+ path = parsed_url .path ,
96+ params = parsed_url .params ,
97+ query = parsed_url .query ,
98+ fragment = None ,
99+ )
53100 return urlunparse (modified_url )
54101
55102
@@ -63,54 +110,68 @@ def __init__(self, content: str, child_link_list: List[ChildLink], status, messa
63110
64111 @staticmethod
65112 def success (html_content : str , child_link_list : List [ChildLink ]):
66- return Fork .Response (html_content , child_link_list , 200 , '' )
113+ return Fork .Response (html_content , child_link_list , 200 , "" )
67114
68115 @staticmethod
69116 def error (message : str ):
70- return Fork .Response ('' , [], 500 , message )
117+ return Fork .Response ("" , [], 500 , message )
71118
72119 def __init__ (self , base_fork_url : str , selector_list : List [str ]):
120+ _validate_url (base_fork_url )
73121 base_fork_url = remove_fragment (base_fork_url )
74122 parsed = urlparse (base_fork_url )
75- path = parsed .path .rstrip ('/' )
76- self .base_fork_url = urlunparse ((
77- parsed .scheme ,
78- parsed .netloc ,
79- path ,
80- None ,
81- None ,
82- None # fragment
83- ))
123+ path = parsed .path .rstrip ("/" )
124+ self .base_fork_url = urlunparse (
125+ (
126+ parsed .scheme ,
127+ parsed .netloc ,
128+ path ,
129+ None ,
130+ None ,
131+ None , # fragment
132+ )
133+ )
84134 parsed = urlsplit (base_fork_url )
85135 query = parsed .query
86136 if query is not None and len (query ) > 0 :
87- self .base_fork_url = self .base_fork_url + '?' + query
137+ self .base_fork_url = self .base_fork_url + "?" + query
88138 self .selector_list = [selector for selector in selector_list if selector is not None and len (selector ) > 0 ]
89139 self .urlparse = urlparse (self .base_fork_url )
90- self .base_url = ParseResult (scheme = self . urlparse . scheme , netloc = self . urlparse . netloc , path = '' , params = '' ,
91- query = '' ,
92- fragment = '' ).geturl ()
140+ self .base_url = ParseResult (
141+ scheme = self . urlparse . scheme , netloc = self . urlparse . netloc , path = "" , params = "" , query = "" , fragment = ""
142+ ).geturl ()
93143
94144 def get_child_link_list (self , bf : BeautifulSoup ):
95145 # Compute the crawl prefix: parent directory when base_fork_url is an HTML file
96146 crawl_prefix = self .base_fork_url
97- if crawl_prefix .endswith ((' .html' , ' .htm' )):
98- crawl_prefix = crawl_prefix .rsplit ('/' , 1 )[0 ]
147+ if crawl_prefix .endswith ((" .html" , " .htm" )):
148+ crawl_prefix = crawl_prefix .rsplit ("/" , 1 )[0 ]
99149 pattern = "^((?!(http:|https:|tel:/|#|mailto:|javascript:))|" + crawl_prefix + "|/).*"
100- link_list = bf .find_all (name = 'a' , href = re .compile (pattern ))
101- result = [ChildLink (link .get ('href' ), link ) if link .get ('href' ).startswith (self .base_url ) else ChildLink (
102- self .base_url + link .get ('href' ), link ) for link in link_list ]
150+ link_list = bf .find_all (name = "a" , href = re .compile (pattern ))
151+ result = [
152+ ChildLink (link .get ("href" ), link )
153+ if link .get ("href" ).startswith (self .base_url )
154+ else ChildLink (self .base_url + link .get ("href" ), link )
155+ for link in link_list
156+ ]
103157 result = [row for row in result if row .url .startswith (crawl_prefix )]
104158 return result
105159
106160 def get_content_html (self , bf : BeautifulSoup ):
107161 if self .selector_list is None or len (self .selector_list ) == 0 :
108162 return str (bf )
109- params = reduce (lambda x , y : {** x , ** y },
110- [{'class_' : selector .replace ('.' , '' )} if selector .startswith ('.' ) else
111- {'id' : selector .replace ("#" , "" )} if selector .startswith ("#" ) else {'name' : selector } for
112- selector in
113- self .selector_list ], {})
163+ params = reduce (
164+ lambda x , y : {** x , ** y },
165+ [
166+ {"class_" : selector .replace ("." , "" )}
167+ if selector .startswith ("." )
168+ else {"id" : selector .replace ("#" , "" )}
169+ if selector .startswith ("#" )
170+ else {"name" : selector }
171+ for selector in self .selector_list
172+ ],
173+ {},
174+ )
114175 f = bf .find_all (** params )
115176 return "\n " .join ([str (row ) for row in f ])
116177
@@ -119,53 +180,58 @@ def reset_url(tag, field, base_fork_url):
119180 field_value : str = tag [field ]
120181 if field_value .startswith ("/" ):
121182 result = urlparse (base_fork_url )
122- result_url = ParseResult (scheme = result .scheme , netloc = result .netloc , path = field_value , params = '' , query = '' ,
123- fragment = '' ).geturl ()
183+ result_url = ParseResult (
184+ scheme = result .scheme , netloc = result .netloc , path = field_value , params = "" , query = "" , fragment = ""
185+ ).geturl ()
124186 else :
125187 # When base_fork_url is an HTML file (not a directory), resolve relative
126188 # links against its parent directory to avoid broken paths like
127189 # /en/index.html/about_dolphindb.html
128- if base_fork_url .endswith ((' .html' , ' .htm' )):
129- base = base_fork_url .rsplit ('/' , 1 )[0 ] + '/'
190+ if base_fork_url .endswith ((" .html" , " .htm" )):
191+ base = base_fork_url .rsplit ("/" , 1 )[0 ] + "/"
130192 else :
131- base = base_fork_url + '/'
193+ base = base_fork_url + "/"
132194 result_url = urljoin (base , field_value )
133- result_url = result_url [:- 1 ] if result_url .endswith ('/' ) else result_url
195+ result_url = result_url [:- 1 ] if result_url .endswith ("/" ) else result_url
134196 tag [field ] = result_url
135197
136198 def reset_beautiful_soup (self , bf : BeautifulSoup ):
137199 reset_config_list = [
138200 {
139- ' field' : ' href' ,
201+ " field" : " href" ,
140202 },
141203 {
142- ' field' : ' src' ,
143- }
204+ " field" : " src" ,
205+ },
144206 ]
145207 for reset_config in reset_config_list :
146- field = reset_config .get (' field' )
147- tag_list = bf .find_all (** {field : re .compile (' ^(?!(http:|https:|tel:/|#|mailto:|javascript:)).*' )})
208+ field = reset_config .get (" field" )
209+ tag_list = bf .find_all (** {field : re .compile (" ^(?!(http:|https:|tel:/|#|mailto:|javascript:)).*" )})
148210 for tag in tag_list :
149211 self .reset_url (tag , field , self .base_fork_url )
150212 # 去掉 href 以 # 开头的锚点链接,保留文字
151- for a in bf .find_all ('a' , href = re .compile ('^#' )):
213+ for a in bf .find_all ("a" , href = re .compile ("^#" )):
152214 a .unwrap ()
153215 return bf
154216
155217 @staticmethod
156218 def get_beautiful_soup (response ):
157- encoding = response .encoding if response .encoding is not None and response .encoding != 'ISO-8859-1' else response .apparent_encoding
219+ encoding = (
220+ response .encoding
221+ if response .encoding is not None and response .encoding != "ISO-8859-1"
222+ else response .apparent_encoding
223+ )
158224 html_content = response .content .decode (encoding )
159225 beautiful_soup = BeautifulSoup (html_content , "html.parser" )
160- meta_list = beautiful_soup .find_all (' meta' )
226+ meta_list = beautiful_soup .find_all (" meta" )
161227 charset_list = Fork .get_charset_list (meta_list )
162228 if len (charset_list ) > 0 :
163229 charset = charset_list [0 ]
164230 if charset != encoding :
165231 try :
166- html_content = response .content .decode (charset , errors = ' replace' )
232+ html_content = response .content .decode (charset , errors = " replace" )
167233 except Exception as e :
168- maxkb_logger .error (f' { e } : { traceback .format_exc ()} ' )
234+ maxkb_logger .error (f" { e } : { traceback .format_exc ()} " )
169235 return BeautifulSoup (html_content , "html.parser" )
170236 return beautiful_soup
171237
@@ -174,39 +240,50 @@ def get_charset_list(meta_list):
174240 charset_list = []
175241 for meta in meta_list :
176242 if meta .attrs is not None :
177- if ' charset' in meta .attrs :
178- charset_list .append (meta .attrs .get (' charset' ))
179- elif meta .attrs .get (' http-equiv' , '' ).lower () == ' content-type' and ' content' in meta .attrs :
180- match = re .search (r' charset=([^\s;]+)' , meta .attrs [' content' ], re .I )
243+ if " charset" in meta .attrs :
244+ charset_list .append (meta .attrs .get (" charset" ))
245+ elif meta .attrs .get (" http-equiv" , "" ).lower () == " content-type" and " content" in meta .attrs :
246+ match = re .search (r" charset=([^\s;]+)" , meta .attrs [" content" ], re .I )
181247 if match :
182248 charset_list .append (match .group (1 ))
183249 return charset_list
184250
185251 def fork (self ):
186252 try :
187-
188253 headers = {
189- ' user-agent' : ' Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'
254+ " user-agent" : " Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36"
190255 }
191256
192- maxkb_logger .info (f'fork:{ self .base_fork_url } ' )
193- response = requests .get (self .base_fork_url , verify = False , headers = headers )
257+ maxkb_logger .info (f"fork:{ self .base_fork_url } " )
258+ url = self .base_fork_url
259+ for _ in range (10 ):
260+ response = requests .get (url , verify = True , headers = headers , allow_redirects = False , timeout = (10 , 30 ))
261+ if response .status_code in (301 , 302 , 303 , 307 , 308 ):
262+ location = response .headers .get ("Location" )
263+ if not location :
264+ break
265+ location = urljoin (url , location )
266+ _validate_url (location )
267+ url = location
268+ continue
269+ break
194270 if response .status_code != 200 :
195271 maxkb_logger .error (f"url: { self .base_fork_url } code:{ response .status_code } " )
196272 return Fork .Response .error (f"url: { self .base_fork_url } code:{ response .status_code } " )
197273 bf = self .get_beautiful_soup (response )
198274 except Exception as e :
199- maxkb_logger .error (f' { str (e )} :{ traceback .format_exc ()} ' )
275+ maxkb_logger .error (f" { str (e )} :{ traceback .format_exc ()} " )
200276 return Fork .Response .error (str (e ))
201277 bf = self .reset_beautiful_soup (bf )
202278 link_list = self .get_child_link_list (bf )
203279 content = self .get_content_html (bf )
204280
205- r = markdownify (content , heading_style = ' ATX' )
281+ r = markdownify (content , heading_style = " ATX" )
206282 return Fork .Response .success (r , link_list )
207283
208284
209285def handler (base_url , response : Fork .Response ):
210286 maxkb_logger .info (base_url .url , base_url .tag .text if base_url .tag else None , response .content )
211287
288+
212289# ForkManage('https://bbs.fit2cloud.com/c/de/6', ['.md-content']).fork(3, set(), handler)
0 commit comments