From 800da13c8058dcede01a5039947ade796528a457 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Mon, 25 Nov 2024 14:24:25 +0100 Subject: [PATCH 01/21] Use kotlin for build.gradle to use only one programming language Changed assertion method to compare HTML directly for better diffs Some tests are not working due to formatting --- build.gradle | 154 ---------- build.gradle.kts | 121 ++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 54788 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 286 +++++++++++------- .../readability4j/processor/ArticleGrabber.kt | 10 +- .../readability4j/processor/MetadataParser.kt | 2 +- .../readability4j/processor/Preprocessor.kt | 2 +- .../readability4j/Readability4JTestBase.kt | 17 +- .../readability4j/util/TestDataGenerator.kt | 2 +- 10 files changed, 316 insertions(+), 284 deletions(-) delete mode 100644 build.gradle create mode 100644 build.gradle.kts diff --git a/build.gradle b/build.gradle deleted file mode 100644 index fe3256b..0000000 --- a/build.gradle +++ /dev/null @@ -1,154 +0,0 @@ -group 'net.dankito.readability4j' -version '1.0.8' -def mavenArtifactId = "readability4j" - -buildscript { - ext { - kotlin_version = '1.3.72' - - slf4jVersion = '1.7.25' - - jsoupVersion = '1.11.2' - - jacksonVersion = '2.9.2' - - logbackVersion = '1.2.3' - - diffUtilsVersion = '2.2' - - okHttpVersion = '3.9.1' - } - - repositories { - mavenCentral() - } - dependencies { - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.10.0" - } -} - - -apply plugin: 'java' -apply plugin: 'kotlin' - -apply plugin: 'maven' -apply plugin: 'signing' - -// So after executing uploadArchives staged repository can be closed and released by executing closeAndReleaseRepository -apply plugin: 'io.codearte.nexus-staging' - - -sourceCompatibility = 1.7 // for Android use compatibility with Java 7 - -repositories { - mavenCentral() -} - -dependencies { - compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - - compile "org.slf4j:slf4j-api:$slf4jVersion" - - compile "org.jsoup:jsoup:$jsoupVersion" - - - testCompile "junit:junit:4.12" - - testCompile "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" - - testCompile "com.github.wumpz:diffutils:$diffUtilsVersion" - - testCompile "com.squareup.okhttp3:okhttp:$okHttpVersion" - - testCompile "ch.qos.logback:logback-core:$logbackVersion" - testCompile "ch.qos.logback:logback-classic:$logbackVersion" -} - - -compileKotlin { - kotlinOptions.jvmTarget = "1.6" -} - -compileTestKotlin { - kotlinOptions.jvmTarget = "1.6" -} - - - -/* publish to maven central */ - -// set ossrhUsername and ossrhPassword in your gradle.properties (in ~/.gradle/gradle.properties or project's gradle.properties) -def areOssrhPropertiesSet = isPropertySet('ossrhUsername') && isPropertySet('ossrhPassword') - -def isPropertySet(propertyName) { - return properties[propertyName] != null && ! (properties[propertyName] as String).isEmpty() -} - -task javadocJar(type: Jar) { - classifier = 'javadoc' - from javadoc -} - -task sourcesJar(type: Jar) { - classifier = 'sources' - from sourceSets.main.allSource -} - -artifacts { - archives javadocJar, sourcesJar -} - -signing { - sign configurations.archives -} - -uploadArchives { - repositories { - mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - repository url: repositories.mavenLocal().url - - if (areOssrhPropertiesSet) { - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } - - snapshotRepository(url: "https://oss.sonatype.org/content/repositories/snapshots/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } - } - - pom.project { - name mavenArtifactId - artifactId = mavenArtifactId - packaging 'jar' - // optionally artifactId can be defined here - description "A Kotlin port of Mozilla‘s Readability. It extracts a website‘s relevant content and removes all clutter from it." - url 'https://github.com/dankito/Readability4J' - - scm { - connection 'scm:git:git://github.com/dankito/Readability4J.git' - developerConnection 'scm:git:git@github.com:dankito/Readability4J.git' - url 'https://github.com/dankito/Readability4J' - } - - licenses { - license { - name 'The Apache License, Version 2.0' - url 'http://www.apache.org/licenses/LICENSE-2.0.txt' - } - } - - developers { - developer { - id 'dankito' - name 'Christian Dankl' - email 'maven@dankito.net' - } - } - } - } - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..47e6ca8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,121 @@ +plugins { + java + id("org.jetbrains.kotlin.jvm") version "1.9.10" + id("maven-publish") + id("signing") +} + +group = "net.dankito.readability4j" +version = "1.0.8" + +val mavenArtifactId = "readability4j" + +object versions { + const val kotlin = "1.9.10" + const val slf4j = "2.0.9" + const val jsoup = "1.16.1" + const val jackson = "2.15.2" + const val logback = "1.4.11" + const val diffUtils = "4.15" + const val okHttp = "4.11.0" + const val junit = "4.13.2" +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}") + implementation("org.slf4j:slf4j-api:${versions.slf4j}") + implementation("org.jsoup:jsoup:${versions.jsoup}") + + testImplementation("junit:junit:${versions.junit}") + testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:${versions.jackson}") + testImplementation("io.github.java-diff-utils:java-diff-utils:${versions.diffUtils}") + testImplementation("com.squareup.okhttp3:okhttp:${versions.okHttp}") + testImplementation("ch.qos.logback:logback-core:${versions.logback}") + testImplementation("ch.qos.logback:logback-classic:${versions.logback}") + testImplementation("org.jetbrains.kotlin:kotlin-test:${versions.kotlin}") +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + } +} + +// Tasks for generating additional artifacts +tasks.register("javadocJar") { + archiveClassifier.set("javadoc") + from(tasks.javadoc) +} + +tasks.register("sourcesJar") { + archiveClassifier.set("sources") + from(sourceSets.main.get().allSource) +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + artifact(tasks.named("javadocJar")) + artifact(tasks.named("sourcesJar")) + + pom { + name.set(mavenArtifactId) + description.set("A Kotlin port of Mozilla's Readability. It extracts a website's relevant content and removes all clutter from it.") + url.set("https://github.com/dankito/Readability4J") + + scm { + connection.set("scm:git:git://github.com/dankito/Readability4J.git") + developerConnection.set("scm:git:git@github.com:dankito/Readability4J.git") + url.set("https://github.com/dankito/Readability4J") + } + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set("dankito") + name.set("Christian Dankl") + email.set("maven@dankito.net") + } + } + } + } + } + + repositories { + maven { + name = "OSSRH" + url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") + + credentials { + username = project.findProperty("ossrhUsername") as String? ?: "" + password = project.findProperty("ossrhPassword") as String? ?: "" + } + } + } +} + +signing { + useInMemoryPgpKeys( + project.findProperty("signing.keyId") as String? ?: "", + project.findProperty("signing.secretKeyRingFile") as String? ?: "", + project.findProperty("signing.password") as String? ?: "" + ) + sign(publishing.publications["mavenJava"]) +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0c6e54a069c8c3659aff7dec593ffc84418733ad..249e5832f090a2944b7473328c07c9755baa3196 100644 GIT binary patch literal 60756 zcmb5WV{~QRw(p$^Dz@00IL3?^hro$gg*4VI_WAaTyVM5Foj~O|-84 z$;06hMwt*rV;^8iB z1~&0XWpYJmG?Ts^K9PC62H*`G}xom%S%yq|xvG~FIfP=9*f zZoDRJBm*Y0aId=qJ?7dyb)6)JGWGwe)MHeNSzhi)Ko6J<-m@v=a%NsP537lHe0R* z`If4$aaBA#S=w!2z&m>{lpTy^Lm^mg*3?M&7HFv}7K6x*cukLIGX;bQG|QWdn{%_6 zHnwBKr84#B7Z+AnBXa16a?or^R?+>$4`}{*a_>IhbjvyTtWkHw)|ay)ahWUd-qq$~ zMbh6roVsj;_qnC-R{G+Cy6bApVOinSU-;(DxUEl!i2)1EeQ9`hrfqj(nKI7?Z>Xur zoJz-a`PxkYit1HEbv|jy%~DO^13J-ut986EEG=66S}D3!L}Efp;Bez~7tNq{QsUMm zh9~(HYg1pA*=37C0}n4g&bFbQ+?-h-W}onYeE{q;cIy%eZK9wZjSwGvT+&Cgv z?~{9p(;bY_1+k|wkt_|N!@J~aoY@|U_RGoWX<;p{Nu*D*&_phw`8jYkMNpRTWx1H* z>J-Mi_!`M468#5Aix$$u1M@rJEIOc?k^QBc?T(#=n&*5eS#u*Y)?L8Ha$9wRWdH^3D4|Ps)Y?m0q~SiKiSfEkJ!=^`lJ(%W3o|CZ zSrZL-Xxc{OrmsQD&s~zPfNJOpSZUl%V8tdG%ei}lQkM+z@-4etFPR>GOH9+Y_F<3=~SXln9Kb-o~f>2a6Xz@AS3cn^;c_>lUwlK(n>z?A>NbC z`Ud8^aQy>wy=$)w;JZzA)_*Y$Z5hU=KAG&htLw1Uh00yE!|Nu{EZkch zY9O6x7Y??>!7pUNME*d!=R#s)ghr|R#41l!c?~=3CS8&zr6*aA7n9*)*PWBV2w+&I zpW1-9fr3j{VTcls1>ua}F*bbju_Xq%^v;-W~paSqlf zolj*dt`BBjHI)H9{zrkBo=B%>8}4jeBO~kWqO!~Thi!I1H(in=n^fS%nuL=X2+s!p}HfTU#NBGiwEBF^^tKU zbhhv+0dE-sbK$>J#t-J!B$TMgN@Wh5wTtK2BG}4BGfsZOoRUS#G8Cxv|6EI*n&Xxq zt{&OxCC+BNqz$9b0WM7_PyBJEVObHFh%%`~!@MNZlo*oXDCwDcFwT~Rls!aApL<)^ zbBftGKKBRhB!{?fX@l2_y~%ygNFfF(XJzHh#?`WlSL{1lKT*gJM zs>bd^H9NCxqxn(IOky5k-wALFowQr(gw%|`0991u#9jXQh?4l|l>pd6a&rx|v=fPJ z1mutj{YzpJ_gsClbWFk(G}bSlFi-6@mwoQh-XeD*j@~huW4(8ub%^I|azA)h2t#yG z7e_V_<4jlM3D(I+qX}yEtqj)cpzN*oCdYHa!nm%0t^wHm)EmFP*|FMw!tb@&`G-u~ zK)=Sf6z+BiTAI}}i{*_Ac$ffr*Wrv$F7_0gJkjx;@)XjYSh`RjAgrCck`x!zP>Ifu z&%he4P|S)H*(9oB4uvH67^0}I-_ye_!w)u3v2+EY>eD3#8QR24<;7?*hj8k~rS)~7 zSXs5ww)T(0eHSp$hEIBnW|Iun<_i`}VE0Nc$|-R}wlSIs5pV{g_Dar(Zz<4X3`W?K z6&CAIl4U(Qk-tTcK{|zYF6QG5ArrEB!;5s?tW7 zrE3hcFY&k)+)e{+YOJ0X2uDE_hd2{|m_dC}kgEKqiE9Q^A-+>2UonB+L@v3$9?AYw zVQv?X*pK;X4Ovc6Ev5Gbg{{Eu*7{N3#0@9oMI~}KnObQE#Y{&3mM4`w%wN+xrKYgD zB-ay0Q}m{QI;iY`s1Z^NqIkjrTlf`B)B#MajZ#9u41oRBC1oM1vq0i|F59> z#StM@bHt|#`2)cpl_rWB($DNJ3Lap}QM-+A$3pe}NyP(@+i1>o^fe-oxX#Bt`mcQc zb?pD4W%#ep|3%CHAYnr*^M6Czg>~L4?l16H1OozM{P*en298b+`i4$|w$|4AHbzqB zHpYUsHZET$Z0ztC;U+0*+amF!@PI%^oUIZy{`L{%O^i{Xk}X0&nl)n~tVEpcAJSJ} zverw15zP1P-O8h9nd!&hj$zuwjg?DoxYIw{jWM zW5_pj+wFy8Tsa9g<7Qa21WaV&;ejoYflRKcz?#fSH_)@*QVlN2l4(QNk| z4aPnv&mrS&0|6NHq05XQw$J^RR9T{3SOcMKCXIR1iSf+xJ0E_Wv?jEc*I#ZPzyJN2 zUG0UOXHl+PikM*&g$U@g+KbG-RY>uaIl&DEtw_Q=FYq?etc!;hEC_}UX{eyh%dw2V zTTSlap&5>PY{6I#(6`j-9`D&I#|YPP8a;(sOzgeKDWsLa!i-$frD>zr-oid!Hf&yS z!i^cr&7tN}OOGmX2)`8k?Tn!!4=tz~3hCTq_9CdiV!NIblUDxHh(FJ$zs)B2(t5@u z-`^RA1ShrLCkg0)OhfoM;4Z{&oZmAec$qV@ zGQ(7(!CBk<5;Ar%DLJ0p0!ResC#U<+3i<|vib1?{5gCebG7$F7URKZXuX-2WgF>YJ^i zMhHDBsh9PDU8dlZ$yJKtc6JA#y!y$57%sE>4Nt+wF1lfNIWyA`=hF=9Gj%sRwi@vd z%2eVV3y&dvAgyuJ=eNJR+*080dbO_t@BFJO<@&#yqTK&+xc|FRR;p;KVk@J3$S{p` zGaMj6isho#%m)?pOG^G0mzOAw0z?!AEMsv=0T>WWcE>??WS=fII$t$(^PDPMU(P>o z_*0s^W#|x)%tx8jIgZY~A2yG;US0m2ZOQt6yJqW@XNY_>_R7(Nxb8Ged6BdYW6{prd!|zuX$@Q2o6Ona8zzYC1u!+2!Y$Jc9a;wy+pXt}o6~Bu1oF1c zp7Y|SBTNi@=I(K%A60PMjM#sfH$y*c{xUgeSpi#HB`?|`!Tb&-qJ3;vxS!TIzuTZs-&%#bAkAyw9m4PJgvey zM5?up*b}eDEY+#@tKec)-c(#QF0P?MRlD1+7%Yk*jW;)`f;0a-ZJ6CQA?E%>i2Dt7T9?s|9ZF|KP4;CNWvaVKZ+Qeut;Jith_y{v*Ny6Co6!8MZx;Wgo z=qAi%&S;8J{iyD&>3CLCQdTX*$+Rx1AwA*D_J^0>suTgBMBb=*hefV+Ars#mmr+YsI3#!F@Xc1t4F-gB@6aoyT+5O(qMz*zG<9Qq*f0w^V!03rpr*-WLH}; zfM{xSPJeu6D(%8HU%0GEa%waFHE$G?FH^kMS-&I3)ycx|iv{T6Wx}9$$D&6{%1N_8 z_CLw)_9+O4&u94##vI9b-HHm_95m)fa??q07`DniVjAy`t7;)4NpeyAY(aAk(+T_O z1om+b5K2g_B&b2DCTK<>SE$Ode1DopAi)xaJjU>**AJK3hZrnhEQ9E`2=|HHe<^tv z63e(bn#fMWuz>4erc47}!J>U58%<&N<6AOAewyzNTqi7hJc|X{782&cM zHZYclNbBwU6673=!ClmxMfkC$(CykGR@10F!zN1Se83LR&a~$Ht&>~43OX22mt7tcZUpa;9@q}KDX3O&Ugp6< zLZLfIMO5;pTee1vNyVC$FGxzK2f>0Z-6hM82zKg44nWo|n}$Zk6&;5ry3`(JFEX$q zK&KivAe${e^5ZGc3a9hOt|!UOE&OocpVryE$Y4sPcs4rJ>>Kbi2_subQ9($2VN(3o zb~tEzMsHaBmBtaHAyES+d3A(qURgiskSSwUc9CfJ@99&MKp2sooSYZu+-0t0+L*!I zYagjOlPgx|lep9tiU%ts&McF6b0VE57%E0Ho%2oi?=Ks+5%aj#au^OBwNwhec zta6QAeQI^V!dF1C)>RHAmB`HnxyqWx?td@4sd15zPd*Fc9hpDXP23kbBenBxGeD$k z;%0VBQEJ-C)&dTAw_yW@k0u?IUk*NrkJ)(XEeI z9Y>6Vel>#s_v@=@0<{4A{pl=9cQ&Iah0iD0H`q)7NeCIRz8zx;! z^OO;1+IqoQNak&pV`qKW+K0^Hqp!~gSohcyS)?^P`JNZXw@gc6{A3OLZ?@1Uc^I2v z+X!^R*HCm3{7JPq{8*Tn>5;B|X7n4QQ0Bs79uTU%nbqOJh`nX(BVj!#f;#J+WZxx4 z_yM&1Y`2XzhfqkIMO7tB3raJKQS+H5F%o83bM+hxbQ zeeJm=Dvix$2j|b4?mDacb67v-1^lTp${z=jc1=j~QD>7c*@+1?py>%Kj%Ejp7Y-!? z8iYRUlGVrQPandAaxFfks53@2EC#0)%mrnmGRn&>=$H$S8q|kE_iWko4`^vCS2aWg z#!`RHUGyOt*k?bBYu3*j3u0gB#v(3tsije zgIuNNWNtrOkx@Pzs;A9un+2LX!zw+p3_NX^Sh09HZAf>m8l@O*rXy_82aWT$Q>iyy zqO7Of)D=wcSn!0+467&!Hl))eff=$aneB?R!YykdKW@k^_uR!+Q1tR)+IJb`-6=jj zymzA>Sv4>Z&g&WWu#|~GcP7qP&m*w-S$)7Xr;(duqCTe7p8H3k5>Y-n8438+%^9~K z3r^LIT_K{i7DgEJjIocw_6d0!<;wKT`X;&vv+&msmhAAnIe!OTdybPctzcEzBy88_ zWO{6i4YT%e4^WQZB)KHCvA(0tS zHu_Bg+6Ko%a9~$EjRB90`P(2~6uI@SFibxct{H#o&y40MdiXblu@VFXbhz>Nko;7R z70Ntmm-FePqhb%9gL+7U8@(ch|JfH5Fm)5${8|`Lef>LttM_iww6LW2X61ldBmG0z zax3y)njFe>j*T{i0s8D4=L>X^j0)({R5lMGVS#7(2C9@AxL&C-lZQx~czI7Iv+{%1 z2hEG>RzX4S8x3v#9sgGAnPzptM)g&LB}@%E>fy0vGSa(&q0ch|=ncKjNrK z`jA~jObJhrJ^ri|-)J^HUyeZXz~XkBp$VhcTEcTdc#a2EUOGVX?@mYx#Vy*!qO$Jv zQ4rgOJ~M*o-_Wptam=~krnmG*p^j!JAqoQ%+YsDFW7Cc9M%YPiBOrVcD^RY>m9Pd< zu}#9M?K{+;UIO!D9qOpq9yxUquQRmQNMo0pT`@$pVt=rMvyX)ph(-CCJLvUJy71DI zBk7oc7)-%ngdj~s@76Yse3L^gV0 z2==qfp&Q~L(+%RHP0n}+xH#k(hPRx(!AdBM$JCfJ5*C=K3ts>P?@@SZ_+{U2qFZb>4kZ{Go37{# zSQc+-dq*a-Vy4?taS&{Ht|MLRiS)Sn14JOONyXqPNnpq&2y~)6wEG0oNy>qvod$FF z`9o&?&6uZjhZ4_*5qWVrEfu(>_n2Xi2{@Gz9MZ8!YmjYvIMasE9yVQL10NBrTCczq zcTY1q^PF2l!Eraguf{+PtHV3=2A?Cu&NN&a8V(y;q(^_mFc6)%Yfn&X&~Pq zU1?qCj^LF(EQB1F`8NxNjyV%fde}dEa(Hx=r7$~ts2dzDwyi6ByBAIx$NllB4%K=O z$AHz1<2bTUb>(MCVPpK(E9wlLElo(aSd(Os)^Raum`d(g9Vd_+Bf&V;l=@mM=cC>) z)9b0enb)u_7V!!E_bl>u5nf&Rl|2r=2F3rHMdb7y9E}}F82^$Rf+P8%dKnOeKh1vs zhH^P*4Ydr^$)$h@4KVzxrHyy#cKmWEa9P5DJ|- zG;!Qi35Tp7XNj60=$!S6U#!(${6hyh7d4q=pF{`0t|N^|L^d8pD{O9@tF~W;#Je*P z&ah%W!KOIN;SyAEhAeTafJ4uEL`(RtnovM+cb(O#>xQnk?dzAjG^~4$dFn^<@-Na3 z395;wBnS{t*H;Jef2eE!2}u5Ns{AHj>WYZDgQJt8v%x?9{MXqJsGP|l%OiZqQ1aB! z%E=*Ig`(!tHh>}4_z5IMpg{49UvD*Pp9!pxt_gdAW%sIf3k6CTycOT1McPl=_#0?8 zVjz8Hj*Vy9c5-krd-{BQ{6Xy|P$6LJvMuX$* zA+@I_66_ET5l2&gk9n4$1M3LN8(yEViRx&mtd#LD}AqEs?RW=xKC(OCWH;~>(X6h!uDxXIPH06xh z*`F4cVlbDP`A)-fzf>MuScYsmq&1LUMGaQ3bRm6i7OsJ|%uhTDT zlvZA1M}nz*SalJWNT|`dBm1$xlaA>CCiQ zK`xD-RuEn>-`Z?M{1%@wewf#8?F|(@1e0+T4>nmlSRrNK5f)BJ2H*$q(H>zGD0>eL zQ!tl_Wk)k*e6v^m*{~A;@6+JGeWU-q9>?+L_#UNT%G?4&BnOgvm9@o7l?ov~XL+et zbGT)|G7)KAeqb=wHSPk+J1bdg7N3$vp(ekjI1D9V$G5Cj!=R2w=3*4!z*J-r-cyeb zd(i2KmX!|Lhey!snRw z?#$Gu%S^SQEKt&kep)up#j&9}e+3=JJBS(s>MH+|=R(`8xK{mmndWo_r`-w1#SeRD&YtAJ#GiVI*TkQZ}&aq<+bU2+coU3!jCI6E+Ad_xFW*ghnZ$q zAoF*i&3n1j#?B8x;kjSJD${1jdRB;)R*)Ao!9bd|C7{;iqDo|T&>KSh6*hCD!rwv= zyK#F@2+cv3=|S1Kef(E6Niv8kyLVLX&e=U;{0x{$tDfShqkjUME>f8d(5nzSkY6@! z^-0>DM)wa&%m#UF1F?zR`8Y3X#tA!*7Q$P3lZJ%*KNlrk_uaPkxw~ zxZ1qlE;Zo;nb@!SMazSjM>;34ROOoygo%SF);LL>rRonWwR>bmSd1XD^~sGSu$Gg# zFZ`|yKU0%!v07dz^v(tY%;So(e`o{ZYTX`hm;@b0%8|H>VW`*cr8R%3n|ehw2`(9B+V72`>SY}9^8oh$En80mZK9T4abVG*to;E z1_S6bgDOW?!Oy1LwYy=w3q~KKdbNtyH#d24PFjX)KYMY93{3-mPP-H>@M-_>N~DDu zENh~reh?JBAK=TFN-SfDfT^=+{w4ea2KNWXq2Y<;?(gf(FgVp8Zp-oEjKzB%2Iqj;48GmY3h=bcdYJ}~&4tS`Q1sb=^emaW$IC$|R+r-8V- zf0$gGE(CS_n4s>oicVk)MfvVg#I>iDvf~Ov8bk}sSxluG!6#^Z_zhB&U^`eIi1@j( z^CK$z^stBHtaDDHxn+R;3u+>Lil^}fj?7eaGB z&5nl^STqcaBxI@v>%zG|j))G(rVa4aY=B@^2{TFkW~YP!8!9TG#(-nOf^^X-%m9{Z zCC?iC`G-^RcBSCuk=Z`(FaUUe?hf3{0C>>$?Vs z`2Uud9M+T&KB6o4o9kvdi^Q=Bw!asPdxbe#W-Oaa#_NP(qpyF@bVxv5D5))srkU#m zj_KA+#7sqDn*Ipf!F5Byco4HOSd!Ui$l94|IbW%Ny(s1>f4|Mv^#NfB31N~kya9!k zWCGL-$0ZQztBate^fd>R!hXY_N9ZjYp3V~4_V z#eB)Kjr8yW=+oG)BuNdZG?jaZlw+l_ma8aET(s+-x+=F-t#Qoiuu1i`^x8Sj>b^U} zs^z<()YMFP7CmjUC@M=&lA5W7t&cxTlzJAts*%PBDAPuqcV5o7HEnqjif_7xGt)F% zGx2b4w{@!tE)$p=l3&?Bf#`+!-RLOleeRk3 z7#pF|w@6_sBmn1nECqdunmG^}pr5(ZJQVvAt$6p3H(16~;vO>?sTE`Y+mq5YP&PBo zvq!7#W$Gewy`;%6o^!Dtjz~x)T}Bdk*BS#=EY=ODD&B=V6TD2z^hj1m5^d6s)D*wk zu$z~D7QuZ2b?5`p)E8e2_L38v3WE{V`bVk;6fl#o2`) z99JsWhh?$oVRn@$S#)uK&8DL8>An0&S<%V8hnGD7Z^;Y(%6;^9!7kDQ5bjR_V+~wp zfx4m3z6CWmmZ<8gDGUyg3>t8wgJ5NkkiEm^(sedCicP^&3D%}6LtIUq>mXCAt{9eF zNXL$kGcoUTf_Lhm`t;hD-SE)m=iBnxRU(NyL}f6~1uH)`K!hmYZjLI%H}AmEF5RZt z06$wn63GHnApHXZZJ}s^s)j9(BM6e*7IBK6Bq(!)d~zR#rbxK9NVIlgquoMq z=eGZ9NR!SEqP6=9UQg#@!rtbbSBUM#ynF);zKX+|!Zm}*{H z+j=d?aZ2!?@EL7C~%B?6ouCKLnO$uWn;Y6Xz zX8dSwj732u(o*U3F$F=7xwxm>E-B+SVZH;O-4XPuPkLSt_?S0)lb7EEg)Mglk0#eS z9@jl(OnH4juMxY+*r03VDfPx_IM!Lmc(5hOI;`?d37f>jPP$?9jQQIQU@i4vuG6MagEoJrQ=RD7xt@8E;c zeGV*+Pt+t$@pt!|McETOE$9k=_C!70uhwRS9X#b%ZK z%q(TIUXSS^F0`4Cx?Rk07C6wI4!UVPeI~-fxY6`YH$kABdOuiRtl73MqG|~AzZ@iL&^s?24iS;RK_pdlWkhcF z@Wv-Om(Aealfg)D^adlXh9Nvf~Uf@y;g3Y)i(YP zEXDnb1V}1pJT5ZWyw=1i+0fni9yINurD=EqH^ciOwLUGi)C%Da)tyt=zq2P7pV5-G zR7!oq28-Fgn5pW|nlu^b!S1Z#r7!Wtr{5J5PQ>pd+2P7RSD?>(U7-|Y z7ZQ5lhYIl_IF<9?T9^IPK<(Hp;l5bl5tF9>X-zG14_7PfsA>6<$~A338iYRT{a@r_ zuXBaT=`T5x3=s&3=RYx6NgG>No4?5KFBVjE(swfcivcIpPQFx5l+O;fiGsOrl5teR z_Cm+;PW}O0Dwe_(4Z@XZ)O0W-v2X><&L*<~*q3dg;bQW3g7)a#3KiQP>+qj|qo*Hk z?57>f2?f@`=Fj^nkDKeRkN2d$Z@2eNKpHo}ksj-$`QKb6n?*$^*%Fb3_Kbf1(*W9K>{L$mud2WHJ=j0^=g30Xhg8$#g^?36`p1fm;;1@0Lrx+8t`?vN0ZorM zSW?rhjCE8$C|@p^sXdx z|NOHHg+fL;HIlqyLp~SSdIF`TnSHehNCU9t89yr@)FY<~hu+X`tjg(aSVae$wDG*C zq$nY(Y494R)hD!i1|IIyP*&PD_c2FPgeY)&mX1qujB1VHPG9`yFQpLFVQ0>EKS@Bp zAfP5`C(sWGLI?AC{XEjLKR4FVNw(4+9b?kba95ukgR1H?w<8F7)G+6&(zUhIE5Ef% z=fFkL3QKA~M@h{nzjRq!Y_t!%U66#L8!(2-GgFxkD1=JRRqk=n%G(yHKn%^&$dW>; zSjAcjETMz1%205se$iH_)ZCpfg_LwvnsZQAUCS#^FExp8O4CrJb6>JquNV@qPq~3A zZ<6dOU#6|8+fcgiA#~MDmcpIEaUO02L5#T$HV0$EMD94HT_eXLZ2Zi&(! z&5E>%&|FZ`)CN10tM%tLSPD*~r#--K(H-CZqIOb99_;m|D5wdgJ<1iOJz@h2Zkq?} z%8_KXb&hf=2Wza(Wgc;3v3TN*;HTU*q2?#z&tLn_U0Nt!y>Oo>+2T)He6%XuP;fgn z-G!#h$Y2`9>Jtf}hbVrm6D70|ERzLAU>3zoWhJmjWfgM^))T+2u$~5>HF9jQDkrXR z=IzX36)V75PrFjkQ%TO+iqKGCQ-DDXbaE;C#}!-CoWQx&v*vHfyI>$HNRbpvm<`O( zlx9NBWD6_e&J%Ous4yp~s6)Ghni!I6)0W;9(9$y1wWu`$gs<$9Mcf$L*piP zPR0Av*2%ul`W;?-1_-5Zy0~}?`e@Y5A&0H!^ApyVTT}BiOm4GeFo$_oPlDEyeGBbh z1h3q&Dx~GmUS|3@4V36&$2uO8!Yp&^pD7J5&TN{?xphf*-js1fP?B|`>p_K>lh{ij zP(?H%e}AIP?_i^f&Li=FDSQ`2_NWxL+BB=nQr=$ zHojMlXNGauvvwPU>ZLq!`bX-5F4jBJ&So{kE5+ms9UEYD{66!|k~3vsP+mE}x!>%P za98bAU0!h0&ka4EoiDvBM#CP#dRNdXJcb*(%=<(g+M@<)DZ!@v1V>;54En?igcHR2 zhubQMq}VSOK)onqHfczM7YA@s=9*ow;k;8)&?J3@0JiGcP! zP#00KZ1t)GyZeRJ=f0^gc+58lc4Qh*S7RqPIC6GugG1gXe$LIQMRCo8cHf^qXgAa2 z`}t>u2Cq1CbSEpLr~E=c7~=Qkc9-vLE%(v9N*&HF`(d~(0`iukl5aQ9u4rUvc8%m) zr2GwZN4!s;{SB87lJB;veebPmqE}tSpT>+`t?<457Q9iV$th%i__Z1kOMAswFldD6 ztbOvO337S5o#ZZgN2G99_AVqPv!?Gmt3pzgD+Hp3QPQ`9qJ(g=kjvD+fUSS3upJn! zqoG7acIKEFRX~S}3|{EWT$kdz#zrDlJU(rPkxjws_iyLKU8+v|*oS_W*-guAb&Pj1 z35Z`3z<&Jb@2Mwz=KXucNYdY#SNO$tcVFr9KdKm|%^e-TXzs6M`PBper%ajkrIyUe zp$vVxVs9*>Vp4_1NC~Zg)WOCPmOxI1V34QlG4!aSFOH{QqSVq1^1)- z0P!Z?tT&E-ll(pwf0?=F=yOzik=@nh1Clxr9}Vij89z)ePDSCYAqw?lVI?v?+&*zH z)p$CScFI8rrwId~`}9YWPFu0cW1Sf@vRELs&cbntRU6QfPK-SO*mqu|u~}8AJ!Q$z znzu}50O=YbjwKCuSVBs6&CZR#0FTu)3{}qJJYX(>QPr4$RqWiwX3NT~;>cLn*_&1H zaKpIW)JVJ>b{uo2oq>oQt3y=zJjb%fU@wLqM{SyaC6x2snMx-}ivfU<1- znu1Lh;i$3Tf$Kh5Uk))G!D1UhE8pvx&nO~w^fG)BC&L!_hQk%^p`Kp@F{cz>80W&T ziOK=Sq3fdRu*V0=S53rcIfWFazI}Twj63CG(jOB;$*b`*#B9uEnBM`hDk*EwSRdwP8?5T?xGUKs=5N83XsR*)a4|ijz|c{4tIU+4j^A5C<#5 z*$c_d=5ml~%pGxw#?*q9N7aRwPux5EyqHVkdJO=5J>84!X6P>DS8PTTz>7C#FO?k#edkntG+fJk8ZMn?pmJSO@`x-QHq;7^h6GEXLXo1TCNhH z8ZDH{*NLAjo3WM`xeb=X{((uv3H(8&r8fJJg_uSs_%hOH%JDD?hu*2NvWGYD+j)&` zz#_1%O1wF^o5ryt?O0n;`lHbzp0wQ?rcbW(F1+h7_EZZ9{>rePvLAPVZ_R|n@;b$;UchU=0j<6k8G9QuQf@76oiE*4 zXOLQ&n3$NR#p4<5NJMVC*S);5x2)eRbaAM%VxWu9ohlT;pGEk7;002enCbQ>2r-us z3#bpXP9g|mE`65VrN`+3mC)M(eMj~~eOf)do<@l+fMiTR)XO}422*1SL{wyY(%oMpBgJagtiDf zz>O6(m;};>Hi=t8o{DVC@YigqS(Qh+ix3Rwa9aliH}a}IlOCW1@?%h_bRbq-W{KHF z%Vo?-j@{Xi@=~Lz5uZP27==UGE15|g^0gzD|3x)SCEXrx`*MP^FDLl%pOi~~Il;dc z^hrwp9sYeT7iZ)-ajKy@{a`kr0-5*_!XfBpXwEcFGJ;%kV$0Nx;apKrur zJN2J~CAv{Zjj%FolyurtW8RaFmpn&zKJWL>(0;;+q(%(Hx!GMW4AcfP0YJ*Vz!F4g z!ZhMyj$BdXL@MlF%KeInmPCt~9&A!;cRw)W!Hi@0DY(GD_f?jeV{=s=cJ6e}JktJw zQORnxxj3mBxfrH=x{`_^Z1ddDh}L#V7i}$njUFRVwOX?qOTKjfPMBO4y(WiU<)epb zvB9L=%jW#*SL|Nd_G?E*_h1^M-$PG6Pc_&QqF0O-FIOpa4)PAEPsyvB)GKasmBoEt z?_Q2~QCYGH+hW31x-B=@5_AN870vY#KB~3a*&{I=f);3Kv7q4Q7s)0)gVYx2#Iz9g(F2;=+Iy4 z6KI^8GJ6D@%tpS^8boU}zpi=+(5GfIR)35PzrbuXeL1Y1N%JK7PG|^2k3qIqHfX;G zQ}~JZ-UWx|60P5?d1e;AHx!_;#PG%d=^X(AR%i`l0jSpYOpXoKFW~7ip7|xvN;2^? zsYC9fanpO7rO=V7+KXqVc;Q5z%Bj})xHVrgoR04sA2 zl~DAwv=!(()DvH*=lyhIlU^hBkA0$e*7&fJpB0|oB7)rqGK#5##2T`@_I^|O2x4GO z;xh6ROcV<9>?e0)MI(y++$-ksV;G;Xe`lh76T#Htuia+(UrIXrf9?

L(tZ$0BqX1>24?V$S+&kLZ`AodQ4_)P#Q3*4xg8}lMV-FLwC*cN$< zt65Rf%7z41u^i=P*qO8>JqXPrinQFapR7qHAtp~&RZ85$>ob|Js;GS^y;S{XnGiBc zGa4IGvDl?x%gY`vNhv8wgZnP#UYI-w*^4YCZnxkF85@ldepk$&$#3EAhrJY0U)lR{F6sM3SONV^+$;Zx8BD&Eku3K zKNLZyBni3)pGzU0;n(X@1fX8wYGKYMpLmCu{N5-}epPDxClPFK#A@02WM3!myN%bkF z|GJ4GZ}3sL{3{qXemy+#Uk{4>Kf8v11;f8I&c76+B&AQ8udd<8gU7+BeWC`akUU~U zgXoxie>MS@rBoyY8O8Tc&8id!w+_ooxcr!1?#rc$-|SBBtH6S?)1e#P#S?jFZ8u-Bs&k`yLqW|{j+%c#A4AQ>+tj$Y z^CZajspu$F%73E68Lw5q7IVREED9r1Ijsg#@DzH>wKseye>hjsk^{n0g?3+gs@7`i zHx+-!sjLx^fS;fY!ERBU+Q zVJ!e0hJH%P)z!y%1^ZyG0>PN@5W~SV%f>}c?$H8r;Sy-ui>aruVTY=bHe}$e zi&Q4&XK!qT7-XjCrDaufT@>ieQ&4G(SShUob0Q>Gznep9fR783jGuUynAqc6$pYX; z7*O@@JW>O6lKIk0G00xsm|=*UVTQBB`u1f=6wGAj%nHK_;Aqmfa!eAykDmi-@u%6~ z;*c!pS1@V8r@IX9j&rW&d*}wpNs96O2Ute>%yt{yv>k!6zfT6pru{F1M3P z2WN1JDYqoTB#(`kE{H676QOoX`cnqHl1Yaru)>8Ky~VU{)r#{&s86Vz5X)v15ULHA zAZDb{99+s~qI6;-dQ5DBjHJP@GYTwn;Dv&9kE<0R!d z8tf1oq$kO`_sV(NHOSbMwr=To4r^X$`sBW4$gWUov|WY?xccQJN}1DOL|GEaD_!@& z15p?Pj+>7d`@LvNIu9*^hPN)pwcv|akvYYq)ks%`G>!+!pW{-iXPZsRp8 z35LR;DhseQKWYSD`%gO&k$Dj6_6q#vjWA}rZcWtQr=Xn*)kJ9kacA=esi*I<)1>w^ zO_+E>QvjP)qiSZg9M|GNeLtO2D7xT6vsj`88sd!94j^AqxFLi}@w9!Y*?nwWARE0P znuI_7A-saQ+%?MFA$gttMV-NAR^#tjl_e{R$N8t2NbOlX373>e7Ox=l=;y#;M7asp zRCz*CLnrm$esvSb5{T<$6CjY zmZ(i{Rs_<#pWW>(HPaaYj`%YqBra=Ey3R21O7vUbzOkJJO?V`4-D*u4$Me0Bx$K(lYo`JO}gnC zx`V}a7m-hLU9Xvb@K2ymioF)vj12<*^oAqRuG_4u%(ah?+go%$kOpfb`T96P+L$4> zQ#S+sA%VbH&mD1k5Ak7^^dZoC>`1L%i>ZXmooA!%GI)b+$D&ziKrb)a=-ds9xk#~& z7)3iem6I|r5+ZrTRe_W861x8JpD`DDIYZNm{$baw+$)X^Jtjnl0xlBgdnNY}x%5za zkQ8E6T<^$sKBPtL4(1zi_Rd(tVth*3Xs!ulflX+70?gb&jRTnI8l+*Aj9{|d%qLZ+ z>~V9Z;)`8-lds*Zgs~z1?Fg?Po7|FDl(Ce<*c^2=lFQ~ahwh6rqSjtM5+$GT>3WZW zj;u~w9xwAhOc<kF}~`CJ68 z?(S5vNJa;kriPlim33{N5`C{9?NWhzsna_~^|K2k4xz1`xcui*LXL-1#Y}Hi9`Oo!zQ>x-kgAX4LrPz63uZ+?uG*84@PKq-KgQlMNRwz=6Yes) zY}>YN+qP}nwr$(CZQFjUOI=-6J$2^XGvC~EZ+vrqWaOXB$k?%Suf5k=4>AveC1aJ! ziaW4IS%F$_Babi)kA8Y&u4F7E%99OPtm=vzw$$ zEz#9rvn`Iot_z-r3MtV>k)YvErZ<^Oa${`2>MYYODSr6?QZu+be-~MBjwPGdMvGd!b!elsdi4% z`37W*8+OGulab8YM?`KjJ8e+jM(tqLKSS@=jimq3)Ea2EB%88L8CaM+aG7;27b?5` z4zuUWBr)f)k2o&xg{iZ$IQkJ+SK>lpq4GEacu~eOW4yNFLU!Kgc{w4&D$4ecm0f}~ zTTzquRW@`f0}|IILl`!1P+;69g^upiPA6F{)U8)muWHzexRenBU$E^9X-uIY2%&1w z_=#5*(nmxJ9zF%styBwivi)?#KMG96-H@hD-H_&EZiRNsfk7mjBq{L%!E;Sqn!mVX*}kXhwH6eh;b42eD!*~upVG@ z#smUqz$ICm!Y8wY53gJeS|Iuard0=;k5i5Z_hSIs6tr)R4n*r*rE`>38Pw&lkv{_r!jNN=;#?WbMj|l>cU(9trCq; z%nN~r^y7!kH^GPOf3R}?dDhO=v^3BeP5hF|%4GNQYBSwz;x({21i4OQY->1G=KFyu z&6d`f2tT9Yl_Z8YACZaJ#v#-(gcyeqXMhYGXb=t>)M@fFa8tHp2x;ODX=Ap@a5I=U z0G80^$N0G4=U(>W%mrrThl0DjyQ-_I>+1Tdd_AuB3qpYAqY54upwa3}owa|x5iQ^1 zEf|iTZxKNGRpI>34EwkIQ2zHDEZ=(J@lRaOH>F|2Z%V_t56Km$PUYu^xA5#5Uj4I4RGqHD56xT%H{+P8Ag>e_3pN$4m8n>i%OyJFPNWaEnJ4McUZPa1QmOh?t8~n& z&RulPCors8wUaqMHECG=IhB(-tU2XvHP6#NrLVyKG%Ee*mQ5Ps%wW?mcnriTVRc4J`2YVM>$ixSF2Xi+Wn(RUZnV?mJ?GRdw%lhZ+t&3s7g!~g{%m&i<6 z5{ib-<==DYG93I(yhyv4jp*y3#*WNuDUf6`vTM%c&hiayf(%=x@4$kJ!W4MtYcE#1 zHM?3xw63;L%x3drtd?jot!8u3qeqctceX3m;tWetK+>~q7Be$h>n6riK(5@ujLgRS zvOym)k+VAtyV^mF)$29Y`nw&ijdg~jYpkx%*^ z8dz`C*g=I?;clyi5|!27e2AuSa$&%UyR(J3W!A=ZgHF9OuKA34I-1U~pyD!KuRkjA zbkN!?MfQOeN>DUPBxoy5IX}@vw`EEB->q!)8fRl_mqUVuRu|C@KD-;yl=yKc=ZT0% zB$fMwcC|HE*0f8+PVlWHi>M`zfsA(NQFET?LrM^pPcw`cK+Mo0%8*x8@65=CS_^$cG{GZQ#xv($7J z??R$P)nPLodI;P!IC3eEYEHh7TV@opr#*)6A-;EU2XuogHvC;;k1aI8asq7ovoP!* z?x%UoPrZjj<&&aWpsbr>J$Er-7!E(BmOyEv!-mbGQGeJm-U2J>74>o5x`1l;)+P&~ z>}f^=Rx(ZQ2bm+YE0u=ZYrAV@apyt=v1wb?R@`i_g64YyAwcOUl=C!i>=Lzb$`tjv zOO-P#A+)t-JbbotGMT}arNhJmmGl-lyUpMn=2UacVZxmiG!s!6H39@~&uVokS zG=5qWhfW-WOI9g4!R$n7!|ViL!|v3G?GN6HR0Pt_L5*>D#FEj5wM1DScz4Jv@Sxnl zB@MPPmdI{(2D?;*wd>3#tjAirmUnQoZrVv`xM3hARuJksF(Q)wd4P$88fGYOT1p6U z`AHSN!`St}}UMBT9o7i|G`r$ zrB=s$qV3d6$W9@?L!pl0lf%)xs%1ko^=QY$ty-57=55PvP(^6E7cc zGJ*>m2=;fOj?F~yBf@K@9qwX0hA803Xw+b0m}+#a(>RyR8}*Y<4b+kpp|OS+!whP( zH`v{%s>jsQI9rd$*vm)EkwOm#W_-rLTHcZRek)>AtF+~<(did)*oR1|&~1|e36d-d zgtm5cv1O0oqgWC%Et@P4Vhm}Ndl(Y#C^MD03g#PH-TFy+7!Osv1z^UWS9@%JhswEq~6kSr2DITo59+; ze=ZC}i2Q?CJ~Iyu?vn|=9iKV>4j8KbxhE4&!@SQ^dVa-gK@YfS9xT(0kpW*EDjYUkoj! zE49{7H&E}k%5(>sM4uGY)Q*&3>{aitqdNnRJkbOmD5Mp5rv-hxzOn80QsG=HJ_atI-EaP69cacR)Uvh{G5dTpYG7d zbtmRMq@Sexey)||UpnZ?;g_KMZq4IDCy5}@u!5&B^-=6yyY{}e4Hh3ee!ZWtL*s?G zxG(A!<9o!CL+q?u_utltPMk+hn?N2@?}xU0KlYg?Jco{Yf@|mSGC<(Zj^yHCvhmyx z?OxOYoxbptDK()tsJ42VzXdINAMWL$0Gcw?G(g8TMB)Khw_|v9`_ql#pRd2i*?CZl z7k1b!jQB=9-V@h%;Cnl7EKi;Y^&NhU0mWEcj8B|3L30Ku#-9389Q+(Yet0r$F=+3p z6AKOMAIi|OHyzlHZtOm73}|ntKtFaXF2Fy|M!gOh^L4^62kGUoWS1i{9gsds_GWBc zLw|TaLP64z3z9?=R2|T6Xh2W4_F*$cq>MtXMOy&=IPIJ`;!Tw?PqvI2b*U1)25^<2 zU_ZPoxg_V0tngA0J+mm?3;OYw{i2Zb4x}NedZug!>EoN3DC{1i)Z{Z4m*(y{ov2%- zk(w>+scOO}MN!exSc`TN)!B=NUX`zThWO~M*ohqq;J2hx9h9}|s#?@eR!=F{QTrq~ zTcY|>azkCe$|Q0XFUdpFT=lTcyW##i;-e{}ORB4D?t@SfqGo_cS z->?^rh$<&n9DL!CF+h?LMZRi)qju!meugvxX*&jfD!^1XB3?E?HnwHP8$;uX{Rvp# zh|)hM>XDv$ZGg=$1{+_bA~u-vXqlw6NH=nkpyWE0u}LQjF-3NhATL@9rRxMnpO%f7 z)EhZf{PF|mKIMFxnC?*78(}{Y)}iztV12}_OXffJ;ta!fcFIVjdchyHxH=t%ci`Xd zX2AUB?%?poD6Zv*&BA!6c5S#|xn~DK01#XvjT!w!;&`lDXSJT4_j$}!qSPrb37vc{ z9^NfC%QvPu@vlxaZ;mIbn-VHA6miwi8qJ~V;pTZkKqqOii<1Cs}0i?uUIss;hM4dKq^1O35y?Yp=l4i zf{M!@QHH~rJ&X~8uATV><23zZUbs-J^3}$IvV_ANLS08>k`Td7aU_S1sLsfi*C-m1 z-e#S%UGs4E!;CeBT@9}aaI)qR-6NU@kvS#0r`g&UWg?fC7|b^_HyCE!8}nyh^~o@< zpm7PDFs9yxp+byMS(JWm$NeL?DNrMCNE!I^ko-*csB+dsf4GAq{=6sfyf4wb>?v1v zmb`F*bN1KUx-`ra1+TJ37bXNP%`-Fd`vVQFTwWpX@;s(%nDQa#oWhgk#mYlY*!d>( zE&!|ySF!mIyfING+#%RDY3IBH_fW$}6~1%!G`suHub1kP@&DoAd5~7J55;5_noPI6eLf{t;@9Kf<{aO0`1WNKd?<)C-|?C?)3s z>wEq@8=I$Wc~Mt$o;g++5qR+(6wt9GI~pyrDJ%c?gPZe)owvy^J2S=+M^ z&WhIE`g;;J^xQLVeCtf7b%Dg#Z2gq9hp_%g)-%_`y*zb; zn9`f`mUPN-Ts&fFo(aNTsXPA|J!TJ{0hZp0^;MYHLOcD=r_~~^ymS8KLCSeU3;^QzJNqS z5{5rEAv#l(X?bvwxpU;2%pQftF`YFgrD1jt2^~Mt^~G>T*}A$yZc@(k9orlCGv&|1 zWWvVgiJsCAtamuAYT~nzs?TQFt<1LSEx!@e0~@yd6$b5!Zm(FpBl;(Cn>2vF?k zOm#TTjFwd2D-CyA!mqR^?#Uwm{NBemP>(pHmM}9;;8`c&+_o3#E5m)JzfwN?(f-a4 zyd%xZc^oQx3XT?vcCqCX&Qrk~nu;fxs@JUoyVoi5fqpi&bUhQ2y!Ok2pzsFR(M(|U zw3E+kH_zmTRQ9dUMZWRE%Zakiwc+lgv7Z%|YO9YxAy`y28`Aw;WU6HXBgU7fl@dnt z-fFBV)}H-gqP!1;V@Je$WcbYre|dRdp{xt!7sL3Eoa%IA`5CAA%;Wq8PktwPdULo! z8!sB}Qt8#jH9Sh}QiUtEPZ6H0b*7qEKGJ%ITZ|vH)5Q^2m<7o3#Z>AKc%z7_u`rXA zqrCy{-{8;9>dfllLu$^M5L z-hXs))h*qz%~ActwkIA(qOVBZl2v4lwbM>9l70Y`+T*elINFqt#>OaVWoja8RMsep z6Or3f=oBnA3vDbn*+HNZP?8LsH2MY)x%c13@(XfuGR}R?Nu<|07{$+Lc3$Uv^I!MQ z>6qWgd-=aG2Y^24g4{Bw9ueOR)(9h`scImD=86dD+MnSN4$6 z^U*o_mE-6Rk~Dp!ANp#5RE9n*LG(Vg`1)g6!(XtDzsov$Dvz|Gv1WU68J$CkshQhS zCrc|cdkW~UK}5NeaWj^F4MSgFM+@fJd{|LLM)}_O<{rj z+?*Lm?owq?IzC%U%9EBga~h-cJbIu=#C}XuWN>OLrc%M@Gu~kFEYUi4EC6l#PR2JS zQUkGKrrS#6H7}2l0F@S11DP`@pih0WRkRJl#F;u{c&ZC{^$Z+_*lB)r)-bPgRFE;* zl)@hK4`tEP=P=il02x7-C7p%l=B`vkYjw?YhdJU9!P!jcmY$OtC^12w?vy3<<=tlY zUwHJ_0lgWN9vf>1%WACBD{UT)1qHQSE2%z|JHvP{#INr13jM}oYv_5#xsnv9`)UAO zuwgyV4YZ;O)eSc3(mka6=aRohi!HH@I#xq7kng?Acdg7S4vDJb6cI5fw?2z%3yR+| zU5v@Hm}vy;${cBp&@D=HQ9j7NcFaOYL zj-wV=eYF{|XTkFNM2uz&T8uH~;)^Zo!=KP)EVyH6s9l1~4m}N%XzPpduPg|h-&lL` zAXspR0YMOKd2yO)eMFFJ4?sQ&!`dF&!|niH*!^*Ml##o0M(0*uK9&yzekFi$+mP9s z>W9d%Jb)PtVi&-Ha!o~Iyh@KRuKpQ@)I~L*d`{O8!kRObjO7=n+Gp36fe!66neh+7 zW*l^0tTKjLLzr`x4`_8&on?mjW-PzheTNox8Hg7Nt@*SbE-%kP2hWYmHu#Fn@Q^J(SsPUz*|EgOoZ6byg3ew88UGdZ>9B2Tq=jF72ZaR=4u%1A6Vm{O#?@dD!(#tmR;eP(Fu z{$0O%=Vmua7=Gjr8nY%>ul?w=FJ76O2js&17W_iq2*tb!i{pt#`qZB#im9Rl>?t?0c zicIC}et_4d+CpVPx)i4~$u6N-QX3H77ez z?ZdvXifFk|*F8~L(W$OWM~r`pSk5}#F?j_5u$Obu9lDWIknO^AGu+Blk7!9Sb;NjS zncZA?qtASdNtzQ>z7N871IsPAk^CC?iIL}+{K|F@BuG2>qQ;_RUYV#>hHO(HUPpk@ z(bn~4|F_jiZi}Sad;_7`#4}EmD<1EiIxa48QjUuR?rC}^HRocq`OQPM@aHVKP9E#q zy%6bmHygCpIddPjE}q_DPC`VH_2m;Eey&ZH)E6xGeStOK7H)#+9y!%-Hm|QF6w#A( zIC0Yw%9j$s-#odxG~C*^MZ?M<+&WJ+@?B_QPUyTg9DJGtQN#NIC&-XddRsf3n^AL6 zT@P|H;PvN;ZpL0iv$bRb7|J{0o!Hq+S>_NrH4@coZtBJu#g8#CbR7|#?6uxi8d+$g z87apN>EciJZ`%Zv2**_uiET9Vk{pny&My;+WfGDw4EVL#B!Wiw&M|A8f1A@ z(yFQS6jfbH{b8Z-S7D2?Ixl`j0{+ZnpT=;KzVMLW{B$`N?Gw^Fl0H6lT61%T2AU**!sX0u?|I(yoy&Xveg7XBL&+>n6jd1##6d>TxE*Vj=8lWiG$4=u{1UbAa5QD>5_ z;Te^42v7K6Mmu4IWT6Rnm>oxrl~b<~^e3vbj-GCdHLIB_>59}Ya+~OF68NiH=?}2o zP(X7EN=quQn&)fK>M&kqF|<_*H`}c zk=+x)GU>{Af#vx&s?`UKUsz})g^Pc&?Ka@t5$n$bqf6{r1>#mWx6Ep>9|A}VmWRnowVo`OyCr^fHsf# zQjQ3Ttp7y#iQY8l`zEUW)(@gGQdt(~rkxlkefskT(t%@i8=|p1Y9Dc5bc+z#n$s13 zGJk|V0+&Ekh(F};PJzQKKo+FG@KV8a<$gmNSD;7rd_nRdc%?9)p!|B-@P~kxQG}~B zi|{0}@}zKC(rlFUYp*dO1RuvPC^DQOkX4<+EwvBAC{IZQdYxoq1Za!MW7%p7gGr=j zzWnAq%)^O2$eItftC#TTSArUyL$U54-O7e|)4_7%Q^2tZ^0-d&3J1}qCzR4dWX!)4 zzIEKjgnYgMus^>6uw4Jm8ga6>GBtMjpNRJ6CP~W=37~||gMo_p@GA@#-3)+cVYnU> zE5=Y4kzl+EbEh%dhQokB{gqNDqx%5*qBusWV%!iprn$S!;oN_6E3?0+umADVs4ako z?P+t?m?};gev9JXQ#Q&KBpzkHPde_CGu-y z<{}RRAx=xlv#mVi+Ibrgx~ujW$h{?zPfhz)Kp7kmYS&_|97b&H&1;J-mzrBWAvY} zh8-I8hl_RK2+nnf&}!W0P+>5?#?7>npshe<1~&l_xqKd0_>dl_^RMRq@-Myz&|TKZBj1=Q()) zF{dBjv5)h=&Z)Aevx}+i|7=R9rG^Di!sa)sZCl&ctX4&LScQ-kMncgO(9o6W6)yd< z@Rk!vkja*X_N3H=BavGoR0@u0<}m-7|2v!0+2h~S2Q&a=lTH91OJsvms2MT~ zY=c@LO5i`mLpBd(vh|)I&^A3TQLtr>w=zoyzTd=^f@TPu&+*2MtqE$Avf>l>}V|3-8Fp2hzo3y<)hr_|NO(&oSD z!vEjTWBxbKTiShVl-U{n*B3#)3a8$`{~Pk}J@elZ=>Pqp|MQ}jrGv7KrNcjW%TN_< zZz8kG{#}XoeWf7qY?D)L)8?Q-b@Na&>i=)(@uNo zr;cH98T3$Iau8Hn*@vXi{A@YehxDE2zX~o+RY`)6-X{8~hMpc#C`|8y> zU8Mnv5A0dNCf{Ims*|l-^ z(MRp{qoGohB34|ggDI*p!Aw|MFyJ|v+<+E3brfrI)|+l3W~CQLPbnF@G0)P~Ly!1TJLp}xh8uW`Q+RB-v`MRYZ9Gam3cM%{ zb4Cb*f)0deR~wtNb*8w-LlIF>kc7DAv>T0D(a3@l`k4TFnrO+g9XH7;nYOHxjc4lq zMmaW6qpgAgy)MckYMhl?>sq;-1E)-1llUneeA!ya9KM$)DaNGu57Z5aE>=VST$#vb zFo=uRHr$0M{-ha>h(D_boS4zId;3B|Tpqo|?B?Z@I?G(?&Iei+-{9L_A9=h=Qfn-U z1wIUnQe9!z%_j$F_{rf&`ZFSott09gY~qrf@g3O=Y>vzAnXCyL!@(BqWa)Zqt!#_k zfZHuwS52|&&)aK;CHq9V-t9qt0au{$#6c*R#e5n3rje0hic7c7m{kW$p(_`wB=Gw7 z4k`1Hi;Mc@yA7dp@r~?@rfw)TkjAW++|pkfOG}0N|2guek}j8Zen(!+@7?qt_7ndX zB=BG6WJ31#F3#Vk3=aQr8T)3`{=p9nBHlKzE0I@v`{vJ}h8pd6vby&VgFhzH|q;=aonunAXL6G2y(X^CtAhWr*jI zGjpY@raZDQkg*aMq}Ni6cRF z{oWv}5`nhSAv>usX}m^GHt`f(t8@zHc?K|y5Zi=4G*UG1Sza{$Dpj%X8 zzEXaKT5N6F5j4J|w#qlZP!zS7BT)9b+!ZSJdToqJts1c!)fwih4d31vfb{}W)EgcA zH2pZ^8_k$9+WD2n`6q5XbOy8>3pcYH9 z07eUB+p}YD@AH!}p!iKv><2QF-Y^&xx^PAc1F13A{nUeCDg&{hnix#FiO!fe(^&%Qcux!h znu*S!s$&nnkeotYsDthh1dq(iQrE|#f_=xVgfiiL&-5eAcC-> z5L0l|DVEM$#ulf{bj+Y~7iD)j<~O8CYM8GW)dQGq)!mck)FqoL^X zwNdZb3->hFrbHFm?hLvut-*uK?zXn3q1z|UX{RZ;-WiLoOjnle!xs+W0-8D)kjU#R z+S|A^HkRg$Ij%N4v~k`jyHffKaC~=wg=9)V5h=|kLQ@;^W!o2^K+xG&2n`XCd>OY5Ydi= zgHH=lgy++erK8&+YeTl7VNyVm9-GfONlSlVb3)V9NW5tT!cJ8d7X)!b-$fb!s76{t z@d=Vg-5K_sqHA@Zx-L_}wVnc@L@GL9_K~Zl(h5@AR#FAiKad8~KeWCo@mgXIQ#~u{ zgYFwNz}2b6Vu@CP0XoqJ+dm8px(5W5-Jpis97F`+KM)TuP*X8H@zwiVKDKGVp59pI zifNHZr|B+PG|7|Y<*tqap0CvG7tbR1R>jn70t1X`XJixiMVcHf%Ez*=xm1(CrTSDt z0cle!+{8*Ja&EOZ4@$qhBuKQ$U95Q%rc7tg$VRhk?3=pE&n+T3upZg^ZJc9~c2es% zh7>+|mrmA-p&v}|OtxqmHIBgUxL~^0+cpfkSK2mhh+4b=^F1Xgd2)}U*Yp+H?ls#z zrLxWg_hm}AfK2XYWr!rzW4g;+^^&bW%LmbtRai9f3PjU${r@n`JThy-cphbcwn)rq9{A$Ht`lmYKxOacy z6v2R(?gHhD5@&kB-Eg?4!hAoD7~(h>(R!s1c1Hx#s9vGPePUR|of32bS`J5U5w{F) z>0<^ktO2UHg<0{oxkdOQ;}coZDQph8p6ruj*_?uqURCMTac;>T#v+l1Tc~%^k-Vd@ zkc5y35jVNc49vZpZx;gG$h{%yslDI%Lqga1&&;mN{Ush1c7p>7e-(zp}6E7f-XmJb4nhk zb8zS+{IVbL$QVF8pf8}~kQ|dHJAEATmmnrb_wLG}-yHe>W|A&Y|;muy-d^t^<&)g5SJfaTH@P1%euONny=mxo+C z4N&w#biWY41r8k~468tvuYVh&XN&d#%QtIf9;iVXfWY)#j=l`&B~lqDT@28+Y!0E+MkfC}}H*#(WKKdJJq=O$vNYCb(ZG@p{fJgu;h z21oHQ(14?LeT>n5)s;uD@5&ohU!@wX8w*lB6i@GEH0pM>YTG+RAIWZD;4#F1&F%Jp zXZUml2sH0!lYJT?&sA!qwez6cXzJEd(1ZC~kT5kZSp7(@=H2$Azb_*W&6aA|9iwCL zdX7Q=42;@dspHDwYE?miGX#L^3xD&%BI&fN9^;`v4OjQXPBaBmOF1;#C)8XA(WFlH zycro;DS2?(G&6wkr6rqC>rqDv3nfGw3hmN_9Al>TgvmGsL8_hXx09};l9Ow@)F5@y z#VH5WigLDwZE4nh^7&@g{1FV^UZ%_LJ-s<{HN*2R$OPg@R~Z`c-ET*2}XB@9xvAjrK&hS=f|R8Gr9 zr|0TGOsI7RD+4+2{ZiwdVD@2zmg~g@^D--YL;6UYGSM8i$NbQr4!c7T9rg!8;TM0E zT#@?&S=t>GQm)*ua|?TLT2ktj#`|R<_*FAkOu2Pz$wEc%-=Y9V*$&dg+wIei3b*O8 z2|m$!jJG!J!ZGbbIa!(Af~oSyZV+~M1qGvelMzPNE_%5?c2>;MeeG2^N?JDKjFYCy z7SbPWH-$cWF9~fX%9~v99L!G(wi!PFp>rB!9xj7=Cv|F+7CsGNwY0Q_J%FID%C^CBZQfJ9K(HK%k31j~e#&?hQ zNuD6gRkVckU)v+53-fc} z7ZCzYN-5RG4H7;>>Hg?LU9&5_aua?A0)0dpew1#MMlu)LHe(M;OHjHIUl7|%%)YPo z0cBk;AOY00%Fe6heoN*$(b<)Cd#^8Iu;-2v@>cE-OB$icUF9EEoaC&q8z9}jMTT2I z8`9;jT%z0;dy4!8U;GW{i`)3!c6&oWY`J3669C!tM<5nQFFrFRglU8f)5Op$GtR-3 zn!+SPCw|04sv?%YZ(a7#L?vsdr7ss@WKAw&A*}-1S|9~cL%uA+E~>N6QklFE>8W|% zyX-qAUGTY1hQ-+um`2|&ji0cY*(qN!zp{YpDO-r>jPk*yuVSay<)cUt`t@&FPF_&$ zcHwu1(SQ`I-l8~vYyUxm@D1UEdFJ$f5Sw^HPH7b!9 zzYT3gKMF((N(v0#4f_jPfVZ=ApN^jQJe-X$`A?X+vWjLn_%31KXE*}5_}d8 zw_B1+a#6T1?>M{ronLbHIlEsMf93muJ7AH5h%;i99<~JX^;EAgEB1uHralD*!aJ@F zV2ruuFe9i2Q1C?^^kmVy921eb=tLDD43@-AgL^rQ3IO9%+vi_&R2^dpr}x{bCVPej z7G0-0o64uyWNtr*loIvslyo0%)KSDDKjfThe0hcqs)(C-MH1>bNGBDRTW~scy_{w} zp^aq8Qb!h9Lwielq%C1b8=?Z=&U)ST&PHbS)8Xzjh2DF?d{iAv)Eh)wsUnf>UtXN( zL7=$%YrZ#|^c{MYmhn!zV#t*(jdmYdCpwqpZ{v&L8KIuKn`@IIZfp!uo}c;7J57N` zAxyZ-uA4=Gzl~Ovycz%MW9ZL7N+nRo&1cfNn9(1H5eM;V_4Z_qVann7F>5f>%{rf= zPBZFaV@_Sobl?Fy&KXyzFDV*FIdhS5`Uc~S^Gjo)aiTHgn#<0C=9o-a-}@}xDor;D zZyZ|fvf;+=3MZd>SR1F^F`RJEZo+|MdyJYQAEauKu%WDol~ayrGU3zzbHKsnHKZ*z zFiwUkL@DZ>!*x05ql&EBq@_Vqv83&?@~q5?lVmffQZ+V-=qL+!u4Xs2Z2zdCQ3U7B&QR9_Iggy} z(om{Y9eU;IPe`+p1ifLx-XWh?wI)xU9ik+m#g&pGdB5Bi<`PR*?92lE0+TkRuXI)z z5LP!N2+tTc%cB6B1F-!fj#}>S!vnpgVU~3!*U1ej^)vjUH4s-bd^%B=ItQqDCGbrEzNQi(dJ`J}-U=2{7-d zK8k^Rlq2N#0G?9&1?HSle2vlkj^KWSBYTwx`2?9TU_DX#J+f+qLiZCqY1TXHFxXZqYMuD@RU$TgcnCC{_(vwZ-*uX)~go#%PK z@}2Km_5aQ~(<3cXeJN6|F8X_1@L%@xTzs}$_*E|a^_URF_qcF;Pfhoe?FTFwvjm1o z8onf@OY@jC2tVcMaZS;|T!Ks(wOgPpRzRnFS-^RZ4E!9dsnj9sFt609a|jJbb1Dt@ z<=Gal2jDEupxUSwWu6zp<<&RnAA;d&4gKVG0iu6g(DsST(4)z6R)zDpfaQ}v{5ARt zyhwvMtF%b-YazR5XLz+oh=mn;y-Mf2a8>7?2v8qX;19y?b>Z5laGHvzH;Nu9S`B8} zI)qN$GbXIQ1VL3lnof^6TS~rvPVg4V?Dl2Bb*K2z4E{5vy<(@@K_cN@U>R!>aUIRnb zL*)=787*cs#zb31zBC49x$`=fkQbMAef)L2$dR{)6BAz!t5U_B#1zZG`^neKSS22oJ#5B=gl%U=WeqL9REF2g zZnfCb0?quf?Ztj$VXvDSWoK`0L=Zxem2q}!XWLoT-kYMOx)!7fcgT35uC~0pySEme z`{wGWTkGr7>+Kb^n;W?BZH6ZP(9tQX%-7zF>vc2}LuWDI(9kh1G#7B99r4x6;_-V+k&c{nPUrR zAXJGRiMe~aup{0qzmLNjS_BC4cB#sXjckx{%_c&^xy{M61xEb>KW_AG5VFXUOjAG4 z^>Qlm9A#1N{4snY=(AmWzatb!ngqiqPbBZ7>Uhb3)dTkSGcL#&SH>iMO-IJBPua`u zo)LWZ>=NZLr758j{%(|uQuZ)pXq_4c!!>s|aDM9#`~1bzK3J1^^D#<2bNCccH7~-X}Ggi!pIIF>uFx%aPARGQsnC8ZQc8lrQ5o~smqOg>Ti^GNme94*w z)JZy{_{#$jxGQ&`M z!OMvZMHR>8*^>eS%o*6hJwn!l8VOOjZQJvh)@tnHVW&*GYPuxqXw}%M!(f-SQf`=L z5;=5w2;%82VMH6Xi&-K3W)o&K^+vJCepWZ-rW%+Dc6X3(){z$@4zjYxQ|}8UIojeC zYZpQ1dU{fy=oTr<4VX?$q)LP}IUmpiez^O&N3E_qPpchGTi5ZM6-2ScWlQq%V&R2Euz zO|Q0Hx>lY1Q1cW5xHv5!0OGU~PVEqSuy#fD72d#O`N!C;o=m+YioGu-wH2k6!t<~K zSr`E=W9)!g==~x9VV~-8{4ZN9{~-A9zJpRe%NGg$+MDuI-dH|b@BD)~>pPCGUNNzY zMDg||0@XGQgw`YCt5C&A{_+J}mvV9Wg{6V%2n#YSRN{AP#PY?1FF1#|vO_%e+#`|2*~wGAJaeRX6=IzFNeWhz6gJc8+(03Ph4y6ELAm=AkN7TOgMUEw*N{= z_)EIDQx5q22oUR+_b*tazu9+pX|n1c*IB-}{DqIj z-?E|ks{o3AGRNb;+iKcHkZvYJvFsW&83RAPs1Oh@IWy%l#5x2oUP6ZCtv+b|q>jsf zZ_9XO;V!>n`UxH1LvH8)L4?8raIvasEhkpQoJ`%!5rBs!0Tu(s_D{`4opB;57)pkX z4$A^8CsD3U5*!|bHIEqsn~{q+Ddj$ME@Gq4JXtgVz&7l{Ok!@?EA{B3P~NAqb9)4? zkQo30A^EbHfQ@87G5&EQTd`frrwL)&Yw?%-W@uy^Gn23%j?Y!Iea2xw<-f;esq zf%w5WN@E1}zyXtYv}}`U^B>W`>XPmdLj%4{P298|SisrE;7HvXX;A}Ffi8B#3Lr;1 zHt6zVb`8{#+e$*k?w8|O{Uh|&AG}|DG1PFo1i?Y*cQm$ZwtGcVgMwtBUDa{~L1KT-{jET4w60>{KZ27vXrHJ;fW{6| z=|Y4!&UX020wU1>1iRgB@Q#m~1^Z^9CG1LqDhYBrnx%IEdIty z!46iOoKlKs)c}newDG)rWUikD%j`)p z_w9Ph&e40=(2eBy;T!}*1p1f1SAUDP9iWy^u^Ubdj21Kn{46;GR+hwLO=4D11@c~V zI8x&(D({K~Df2E)Nx_yQvYfh4;MbMJ@Z}=Dt3_>iim~QZ*hZIlEs0mEb z_54+&*?wMD`2#vsQRN3KvoT>hWofI_Vf(^C1ff-Ike@h@saEf7g}<9T`W;HAne-Nd z>RR+&SP35w)xKn8^U$7))PsM!jKwYZ*RzEcG-OlTrX3}9a{q%#Un5E5W{{hp>w~;` zGky+3(vJvQyGwBo`tCpmo0mo((?nM8vf9aXrrY1Ve}~TuVkB(zeds^jEfI}xGBCM2 zL1|#tycSaWCurP+0MiActG3LCas@_@tao@(R1ANlwB$4K53egNE_;!&(%@Qo$>h`^1S_!hN6 z)vZtG$8fN!|BXBJ=SI>e(LAU(y(i*PHvgQ2llulxS8>qsimv7yL}0q_E5WiAz7)(f zC(ahFvG8&HN9+6^jGyLHM~$)7auppeWh_^zKk&C_MQ~8;N??OlyH~azgz5fe^>~7F zl3HnPN3z-kN)I$4@`CLCMQx3sG~V8hPS^}XDXZrQA>}mQPw%7&!sd(Pp^P=tgp-s^ zjl}1-KRPNWXgV_K^HkP__SR`S-|OF0bR-N5>I%ODj&1JUeAQ3$9i;B~$S6}*^tK?= z**%aCiH7y?xdY?{LgVP}S0HOh%0%LI$wRx;$T|~Y8R)Vdwa}kGWv8?SJVm^>r6+%I z#lj1aR94{@MP;t-scEYQWc#xFA30^}?|BeX*W#9OL;Q9#WqaaM546j5j29((^_8Nu z4uq}ESLr~r*O7E7$D{!k9W>`!SLoyA53i9QwRB{!pHe8um|aDE`Cg0O*{jmor)^t)3`>V>SWN-2VJcFmj^1?~tT=JrP`fVh*t zXHarp=8HEcR#vFe+1a%XXuK+)oFs`GDD}#Z+TJ}Ri`FvKO@ek2ayn}yaOi%(8p%2$ zpEu)v0Jym@f}U|-;}CbR=9{#<^z28PzkkTNvyKvJDZe+^VS2bES3N@Jq!-*}{oQlz z@8bgC_KnDnT4}d#&Cpr!%Yb?E!brx0!eVOw~;lLwUoz#Np%d$o%9scc3&zPm`%G((Le|6o1 zM(VhOw)!f84zG^)tZ1?Egv)d8cdNi+T${=5kV+j;Wf%2{3g@FHp^Gf*qO0q!u$=m9 zCaY`4mRqJ;FTH5`a$affE5dJrk~k`HTP_7nGTY@B9o9vvnbytaID;^b=Tzp7Q#DmD zC(XEN)Ktn39z5|G!wsVNnHi) z%^q94!lL|hF`IijA^9NR0F$@h7k5R^ljOW(;Td9grRN0Mb)l_l7##{2nPQ@?;VjXv zaLZG}yuf$r$<79rVPpXg?6iiieX|r#&`p#Con2i%S8*8F}(E) zI5E6c3tG*<;m~6>!&H!GJ6zEuhH7mkAzovdhLy;)q z{H2*8I^Pb}xC4s^6Y}6bJvMu=8>g&I)7!N!5QG$xseeU#CC?ZM-TbjsHwHgDGrsD= z{%f;@Sod+Ch66Ko2WF~;Ty)v>&x^aovCbCbD7>qF*!?BXmOV3(s|nxsb*Lx_2lpB7 zokUnzrk;P=T-&kUHO}td+Zdj!3n&NR?K~cRU zAXU!DCp?51{J4w^`cV#ye}(`SQhGQkkMu}O3M*BWt4UsC^jCFUy;wTINYmhD$AT;4 z?Xd{HaJjP`raZ39qAm;%beDbrLpbRf(mkKbANan7XsL>_pE2oo^$TgdidjRP!5-`% zv0d!|iKN$c0(T|L0C~XD0aS8t{*&#LnhE;1Kb<9&=c2B+9JeLvJr*AyyRh%@jHej=AetOMSlz^=!kxX>>B{2B1uIrQyfd8KjJ+DBy!h)~*(!|&L4^Q_07SQ~E zcemVP`{9CwFvPFu7pyVGCLhH?LhEVb2{7U+Z_>o25#+3<|8%1T^5dh}*4(kfJGry} zm%r#hU+__Z;;*4fMrX=Bkc@7|v^*B;HAl0((IBPPii%X9+u3DDF6%bI&6?Eu$8&aWVqHIM7mK6?Uvq$1|(-T|)IV<>e?!(rY zqkmO1MRaLeTR=)io(0GVtQT@s6rN%C6;nS3@eu;P#ry4q;^O@1ZKCJyp_Jo)Ty^QW z+vweTx_DLm{P-XSBj~Sl<%_b^$=}odJ!S2wAcxenmzFGX1t&Qp8Vxz2VT`uQsQYtdn&_0xVivIcxZ_hnrRtwq4cZSj1c-SG9 z7vHBCA=fd0O1<4*=lu$6pn~_pVKyL@ztw1swbZi0B?spLo56ZKu5;7ZeUml1Ws1?u zqMf1p{5myAzeX$lAi{jIUqo1g4!zWLMm9cfWcnw`k6*BR^?$2(&yW?>w;G$EmTA@a z6?y#K$C~ZT8+v{87n5Dm&H6Pb_EQ@V0IWmG9cG=O;(;5aMWWrIPzz4Q`mhK;qQp~a z+BbQrEQ+w{SeiuG-~Po5f=^EvlouB@_|4xQXH@A~KgpFHrwu%dwuCR)=B&C(y6J4J zvoGk9;lLs9%iA-IJGU#RgnZZR+@{5lYl8(e1h6&>Vc_mvg0d@);X zji4T|n#lB!>pfL|8tQYkw?U2bD`W{na&;*|znjmalA&f;*U++_aBYerq;&C8Kw7mI z7tsG*?7*5j&dU)Lje;^{D_h`%(dK|pB*A*1(Jj)w^mZ9HB|vGLkF1GEFhu&rH=r=8 zMxO42e{Si6$m+Zj`_mXb&w5Q(i|Yxyg?juUrY}78uo@~3v84|8dfgbPd0iQJRdMj< zncCNGdMEcsxu#o#B5+XD{tsg*;j-eF8`mp~K8O1J!Z0+>0=7O=4M}E?)H)ENE;P*F z$Ox?ril_^p0g7xhDUf(q652l|562VFlC8^r8?lQv;TMvn+*8I}&+hIQYh2 z1}uQQaag&!-+DZ@|C+C$bN6W;S-Z@)d1|en+XGvjbOxCa-qAF*LA=6s(Jg+g;82f$ z(Vb)8I)AH@cdjGFAR5Rqd0wiNCu!xtqWbcTx&5kslzTb^7A78~Xzw1($UV6S^VWiP zFd{Rimd-0CZC_Bu(WxBFW7+k{cOW7DxBBkJdJ;VsJ4Z@lERQr%3eVv&$%)b%<~ zCl^Y4NgO}js@u{|o~KTgH}>!* z_iDNqX2(As7T0xivMH|3SC1ivm8Q}6Ffcd7owUKN5lHAtzMM4<0v+ykUT!QiowO;`@%JGv+K$bBx@*S7C8GJVqQ_K>12}M`f_Ys=S zKFh}HM9#6Izb$Y{wYzItTy+l5U2oL%boCJn?R3?jP@n$zSIwlmyGq30Cw4QBO|14` zW5c);AN*J3&eMFAk$SR~2k|&+&Bc$e>s%c{`?d~85S-UWjA>DS5+;UKZ}5oVa5O(N zqqc@>)nee)+4MUjH?FGv%hm2{IlIF-QX}ym-7ok4Z9{V+ZHVZQl$A*x!(q%<2~iVv znUa+BX35&lCb#9VE-~Y^W_f;Xhl%vgjwdjzMy$FsSIj&ok}L+X`4>J=9BkN&nu^E*gbhj3(+D>C4E z@Fwq_=N)^bKFSHTzZk?-gNU$@l}r}dwGyh_fNi=9b|n}J>&;G!lzilbWF4B}BBq4f zYIOl?b)PSh#XTPp4IS5ZR_2C!E)Z`zH0OW%4;&~z7UAyA-X|sh9@~>cQW^COA9hV4 zXcA6qUo9P{bW1_2`eo6%hgbN%(G-F1xTvq!sc?4wN6Q4`e9Hku zFwvlAcRY?6h^Fj$R8zCNEDq8`=uZB8D-xn)tA<^bFFy}4$vA}Xq0jAsv1&5!h!yRA zU()KLJya5MQ`q&LKdH#fwq&(bNFS{sKlEh_{N%{XCGO+po#(+WCLmKW6&5iOHny>g z3*VFN?mx!16V5{zyuMWDVP8U*|BGT$(%IO|)?EF|OI*sq&RovH!N%=>i_c?K*A>>k zyg1+~++zY4Q)J;VWN0axhoIKx;l&G$gvj(#go^pZskEVj8^}is3Jw26LzYYVos0HX zRPvmK$dVxM8(Tc?pHFe0Z3uq){{#OK3i-ra#@+;*=ui8)y6hsRv z4Fxx1c1+fr!VI{L3DFMwXKrfl#Q8hfP@ajgEau&QMCxd{g#!T^;ATXW)nUg&$-n25 zruy3V!!;{?OTobo|0GAxe`Acn3GV@W=&n;~&9 zQM>NWW~R@OYORkJAo+eq1!4vzmf9K%plR4(tB@TR&FSbDoRgJ8qVcH#;7lQub*nq&?Z>7WM=oeEVjkaG zT#f)=o!M2DO5hLR+op>t0CixJCIeXH*+z{-XS|%jx)y(j&}Wo|3!l7{o)HU3m7LYyhv*xF&tq z%IN7N;D4raue&&hm0xM=`qv`+TK@;_xAcGKuK(2|75~ar2Yw)geNLSmVxV@x89bQu zpViVKKnlkwjS&&c|-X6`~xdnh}Ps)Hs z4VbUL^{XNLf7_|Oi>tA%?SG5zax}esF*FH3d(JH^Gvr7Rp*n=t7frH!U;!y1gJB^i zY_M$KL_}mW&XKaDEi9K-wZR|q*L32&m+2n_8lq$xRznJ7p8}V>w+d@?uB!eS3#u<} zIaqi!b!w}a2;_BfUUhGMy#4dPx>)_>yZ`ai?Rk`}d0>~ce-PfY-b?Csd(28yX22L% zI7XI>OjIHYTk_@Xk;Gu^F52^Gn6E1&+?4MxDS2G_#PQ&yXPXP^<-p|2nLTb@AAQEY zI*UQ9Pmm{Kat}wuazpjSyXCdnrD&|C1c5DIb1TnzF}f4KIV6D)CJ!?&l&{T)e4U%3HTSYqsQ zo@zWB1o}ceQSV)<4G<)jM|@@YpL+XHuWsr5AYh^Q{K=wSV99D~4RRU52FufmMBMmd z_H}L#qe(}|I9ZyPRD6kT>Ivj&2Y?qVZq<4bG_co_DP`sE*_Xw8D;+7QR$Uq(rr+u> z8bHUWbV19i#)@@G4bCco@Xb<8u~wVDz9S`#k@ciJtlu@uP1U0X?yov8v9U3VOig2t zL9?n$P3=1U_Emi$#slR>N5wH-=J&T=EdUHA}_Z zZIl3nvMP*AZS9{cDqFanrA~S5BqxtNm9tlu;^`)3X&V4tMAkJ4gEIPl= zoV!Gyx0N{3DpD@)pv^iS*dl2FwANu;1;%EDl}JQ7MbxLMAp>)UwNwe{=V}O-5C*>F zu?Ny+F64jZn<+fKjF01}8h5H_3pey|;%bI;SFg$w8;IC<8l|3#Lz2;mNNik6sVTG3 z+Su^rIE#40C4a-587$U~%KedEEw1%r6wdvoMwpmlXH$xPnNQN#f%Z7|p)nC>WsuO= z4zyqapLS<8(UJ~Qi9d|dQijb_xhA2)v>la)<1md5s^R1N&PiuA$^k|A<+2C?OiHbj z>Bn$~t)>Y(Zb`8hW7q9xQ=s>Rv81V+UiuZJc<23HplI88isqRCId89fb`Kt|CxVIg znWcwprwXnotO>3s&Oypkte^9yJjlUVVxSe%_xlzmje|mYOVPH^vjA=?6xd0vaj0Oz zwJ4OJNiFdnHJX3rw&inskjryukl`*fRQ#SMod5J|KroJRsVXa5_$q7whSQ{gOi*s0 z1LeCy|JBWRsDPn7jCb4s(p|JZiZ8+*ExC@Vj)MF|*Vp{B(ziccSn`G1Br9bV(v!C2 z6#?eqpJBc9o@lJ#^p-`-=`4i&wFe>2)nlPK1p9yPFzJCzBQbpkcR>={YtamIw)3nt z(QEF;+)4`>8^_LU)_Q3 zC5_7lgi_6y>U%m)m@}Ku4C}=l^J=<<7c;99ec3p{aR+v=diuJR7uZi%aQv$oP?dn?@6Yu_+*^>T0ptf(oobdL;6)N-I!TO`zg^Xbv3#L0I~sn@WGk-^SmPh5>W+LB<+1PU}AKa?FCWF|qMNELOgdxR{ zbqE7@jVe+FklzdcD$!(A$&}}H*HQFTJ+AOrJYnhh}Yvta(B zQ_bW4Rr;R~&6PAKwgLWXS{Bnln(vUI+~g#kl{r+_zbngT`Y3`^Qf=!PxN4IYX#iW4 zucW7@LLJA9Zh3(rj~&SyN_pjO8H&)|(v%!BnMWySBJV=eSkB3YSTCyIeJ{i;(oc%_hk{$_l;v>nWSB)oVeg+blh=HB5JSlG_r7@P z3q;aFoZjD_qS@zygYqCn=;Zxjo!?NK!%J$ z52lOP`8G3feEj+HTp@Tnn9X~nG=;tS+z}u{mQX_J0kxtr)O30YD%oo)L@wy`jpQYM z@M>Me=95k1p*FW~rHiV1CIfVc{K8r|#Kt(ApkXKsDG$_>76UGNhHExFCw#Ky9*B-z zNq2ga*xax!HMf_|Vp-86r{;~YgQKqu7%szk8$hpvi_2I`OVbG1doP(`gn}=W<8%Gn z%81#&WjkH4GV;4u43EtSW>K_Ta3Zj!XF?;SO3V#q=<=>Tc^@?A`i;&`-cYj|;^ zEo#Jl5zSr~_V-4}y8pnufXLa80vZY4z2ko7fj>DR)#z=wWuS1$$W!L?(y}YC+yQ|G z@L&`2upy3f>~*IquAjkVNU>}c10(fq#HdbK$~Q3l6|=@-eBbo>B9(6xV`*)sae58*f zym~RRVx;xoCG3`JV`xo z!lFw)=t2Hy)e!IFs?0~7osWk(d%^wxq&>_XD4+U#y&-VF%4z?XH^i4w`TxpF{`XhZ z%G}iEzf!T(l>g;W9<~K+)$g!{UvhW{E0Lis(S^%I8OF&%kr!gJ&fMOpM=&=Aj@wuL zBX?*6i51Qb$uhkwkFYkaD_UDE+)rh1c;(&Y=B$3)J&iJfQSx!1NGgPtK!$c9OtJuu zX(pV$bfuJpRR|K(dp@^j}i&HeJOh@|7lWo8^$*o~Xqo z5Sb+!EtJ&e@6F+h&+_1ETbg7LfP5GZjvIUIN3ibCOldAv z)>YdO|NH$x7AC8dr=<2ekiY1%fN*r~e5h6Yaw<{XIErujKV~tiyrvV_DV0AzEknC- zR^xKM3i<1UkvqBj3C{wDvytOd+YtDSGu!gEMg+!&|8BQrT*|p)(dwQLEy+ zMtMzij3zo40)CA!BKZF~yWg?#lWhqD3@qR)gh~D{uZaJO;{OWV8XZ_)J@r3=)T|kt zUS1pXr6-`!Z}w2QR7nP%d?ecf90;K_7C3d!UZ`N(TZoWNN^Q~RjVhQG{Y<%E1PpV^4 z-m-K+$A~-+VDABs^Q@U*)YvhY4Znn2^w>732H?NRK(5QSS$V@D7yz2BVX4)f5A04~$WbxGOam22>t&uD)JB8-~yiQW6ik;FGblY_I>SvB_z2?PS z*Qm&qbKI{H1V@YGWzpx`!v)WeLT02};JJo*#f$a*FH?IIad-^(;9XC#YTWN6;Z6+S zm4O1KH=#V@FJw7Pha0!9Vb%ZIM$)a`VRMoiN&C|$YA3~ZC*8ayZRY^fyuP6$n%2IU z$#XceYZeqLTXw(m$_z|33I$B4k~NZO>pP6)H_}R{E$i%USGy{l{-jOE;%CloYPEU+ zRFxOn4;7lIOh!7abb23YKD+_-?O z0FP9otcAh+oSj;=f#$&*ExUHpd&e#bSF%#8*&ItcL2H$Sa)?pt0Xtf+t)z$_u^wZi z44oE}r4kIZGy3!Mc8q$B&6JqtnHZ>Znn!Zh@6rgIu|yU+zG8q`q9%B18|T|oN3zMq z`l&D;U!OL~%>vo&q0>Y==~zLiCZk4v%s_7!9DxQ~id1LLE93gf*gg&2$|hB#j8;?3 z5v4S;oM6rT{Y;I+#FdmNw z){d%tNM<<#GN%n9ox7B=3#;u7unZ~tLB_vRZ52a&2=IM)2VkXm=L+Iqq~uk#Dug|x z>S84e+A7EiOY5lj*!q?6HDkNh~0g;0Jy(al!ZHHDtur9T$y-~)94HelX1NHjXWIM7UAe}$?jiz z9?P4`I0JM=G5K{3_%2jPLC^_Mlw?-kYYgb7`qGa3@dn|^1fRMwiyM@Ch z;CB&o7&&?c5e>h`IM;Wnha0QKnEp=$hA8TJgR-07N~U5(>9vJzeoFsSRBkDq=x(YgEMpb=l4TDD`2 zwVJpWGTA_u7}?ecW7s6%rUs&NXD3+n;jB86`X?8(l3MBo6)PdakI6V6a}22{)8ilT zM~T*mU}__xSy|6XSrJ^%lDAR3Lft%+yxC|ZUvSO_nqMX!_ul3;R#*{~4DA=h$bP)%8Yv9X zyp><|e8=_ttI}ZAwOd#dlnSjck#6%273{E$kJuCGu=I@O)&6ID{nWF5@gLb16sj|&Sb~+du4e4O_%_o`Ix4NRrAsyr1_}MuP94s>de8cH-OUkVPk3+K z&jW)It9QiU-ti~AuJkL`XMca8Oh4$SyJ=`-5WU<{cIh+XVH#e4d&zive_UHC!pN>W z3TB;Mn5i)9Qn)#6@lo4QpI3jFYc0~+jS)4AFz8fVC;lD^+idw^S~Qhq>Tg(!3$yLD zzktzoFrU@6s4wwCMz}edpF5i5Q1IMmEJQHzp(LAt)pgN3&O!&d?3W@6U4)I^2V{;- z6A(?zd93hS*uQmnh4T)nHnE{wVhh(=MMD(h(P4+^p83Om6t<*cUW>l(qJzr%5vp@K zN27ka(L{JX=1~e2^)F^i=TYj&;<7jyUUR2Bek^A8+3Up*&Xwc{)1nRR5CT8vG>ExV zHnF3UqXJOAno_?bnhCX-&kwI~Ti8t4`n0%Up>!U`ZvK^w2+0Cs-b9%w%4`$+To|k= zKtgc&l}P`*8IS>8DOe?EB84^kx4BQp3<7P{Pq}&p%xF_81pg!l2|u=&I{AuUgmF5n zJQCTLv}%}xbFGYtKfbba{CBo)lWW%Z>i(_NvLhoQZ*5-@2l&x>e+I~0Nld3UI9tdL zRzu8}i;X!h8LHVvN?C+|M81e>Jr38%&*9LYQec9Ax>?NN+9(_>XSRv&6hlCYB`>Qm z1&ygi{Y()OU4@D_jd_-7vDILR{>o|7-k)Sjdxkjgvi{@S>6GqiF|o`*Otr;P)kLHN zZkpts;0zw_6;?f(@4S1FN=m!4^mv~W+lJA`&7RH%2$)49z0A+8@0BCHtj|yH--AEL z0tW6G%X-+J+5a{5*WKaM0QDznf;V?L5&uQw+yegDNDP`hA;0XPYc6e0;Xv6|i|^F2WB)Z$LR|HR4 zTQsRAby9(^Z@yATyOgcfQw7cKyr^3Tz7lc7+JEwwzA7)|2x+PtEb>nD(tpxJQm)Kn zW9K_*r!L%~N*vS8<5T=iv|o!zTe9k_2jC_j*7ik^M_ zaf%k{WX{-;0*`t`G!&`eW;gChVXnJ-Rn)To8vW-?>>a%QU1v`ZC=U)f8iA@%JG0mZ zDqH;~mgBnrCP~1II<=V9;EBL)J+xzCoiRBaeH&J6rL!{4zIY8tZka?_FBeQeNO3q6 zyG_alW54Ba&wQf{&F1v-r1R6ID)PTsqjIBc+5MHkcW5Fnvi~{-FjKe)t1bl}Y;z@< z=!%zvpRua>>t_x}^}z0<7MI!H2v6|XAyR9!t50q-A)xk0nflgF4*OQlCGK==4S|wc zRMsSscNhRzHMBU8TdcHN!q^I}x0iXJ%uehac|Zs_B$p@CnF)HeXPpB_Za}F{<@6-4 zl%kml@}kHQ(ypD8FsPJ2=14xXJE|b20RUIgs!2|R3>LUMGF6X*B_I|$`Qg=;zm7C z{mEDy9dTmPbued7mlO@phdmAmJ7p@GR1bjCkMw6*G7#4+`k>fk1czdJUB!e@Q(~6# zwo%@p@V5RL0ABU2LH7Asq^quDUho@H>eTZH9f*no9fY0T zD_-9px3e}A!>>kv5wk91%C9R1J_Nh!*&Kk$J3KNxC}c_@zlgpJZ+5L)Nw|^p=2ue}CJtm;uj*Iqr)K})kA$xtNUEvX;4!Px*^&9T_`IN{D z{6~QY=Nau6EzpvufB^hflc#XIsSq0Y9(nf$d~6ZwK}fal92)fr%T3=q{0mP-EyP_G z)UR5h@IX}3Qll2b0oCAcBF>b*@Etu*aTLPU<%C>KoOrk=x?pN!#f_Og-w+;xbFgjQ zXp`et%lDBBh~OcFnMKMUoox0YwBNy`N0q~bSPh@+enQ=4RUw1) zpovN`QoV>vZ#5LvC;cl|6jPr}O5tu!Ipoyib8iXqy}TeJ;4+_7r<1kV0v5?Kv>fYp zg>9L`;XwXa&W7-jf|9~uP2iyF5`5AJ`Q~p4eBU$MCC00`rcSF>`&0fbd^_eqR+}mK z4n*PMMa&FOcc)vTUR zlDUAn-mh`ahi_`f`=39JYTNVjsTa_Y3b1GOIi)6dY)D}xeshB0T8Eov5%UhWd1)u}kjEQ|LDo{tqKKrYIfVz~@dp!! zMOnah@vp)%_-jDTUG09l+;{CkDCH|Q{NqX*uHa1YxFShy*1+;J`gywKaz|2Q{lG8x zP?KBur`}r`!WLKXY_K;C8$EWG>jY3UIh{+BLv0=2)KH%P}6xE2kg)%(-uA6lC?u8}{K(#P*c zE9C8t*u%j2r_{;Rpe1A{9nNXU;b_N0vNgyK!EZVut~}+R2rcbsHilqsOviYh-pYX= zHw@53nlmwYI5W5KP>&`dBZe0Jn?nAdC^HY1wlR6$u^PbpB#AS&5L6zqrXN&7*N2Q` z+Rae1EwS)H=aVSIkr8Ek^1jy2iS2o7mqm~Mr&g5=jjt7VxwglQ^`h#Mx+x2v|9ZAwE$i_9918MjJxTMr?n!bZ6n$}y11u8I9COTU`Z$Fi z!AeAQLMw^gp_{+0QTEJrhL424pVDp%wpku~XRlD3iv{vQ!lAf!_jyqd_h}+Tr1XG| z`*FT*NbPqvHCUsYAkFnM`@l4u_QH&bszpUK#M~XLJt{%?00GXY?u_{gj3Hvs!=N(I z(=AuWPijyoU!r?aFTsa8pLB&cx}$*%;K$e*XqF{~*rA-qn)h^!(-;e}O#B$|S~c+U zN4vyOK0vmtx$5K!?g*+J@G1NmlEI=pyZXZ69tAv=@`t%ag_Hk{LP~OH9iE)I= zaJ69b4kuCkV0V zo(M0#>phpQ_)@j;h%m{-a*LGi(72TP)ws2w*@4|C-3+;=5DmC4s7Lp95%n%@Ko zfdr3-a7m*dys9iIci$A=4NPJ`HfJ;hujLgU)ZRuJI`n;Pw|yksu!#LQnJ#dJysgNb z@@qwR^wrk(jbq4H?d!lNyy72~Dnn87KxsgQ!)|*m(DRM+eC$wh7KnS-mho3|KE)7h zK3k;qZ;K1Lj6uEXLYUYi)1FN}F@-xJ z@@3Hb84sl|j{4$3J}aTY@cbX@pzB_qM~APljrjju6P0tY{C@ zpUCOz_NFmALMv1*blCcwUD3?U6tYs+N%cmJ98D%3)%)Xu^uvzF zS5O!sc#X6?EwsYkvPo6A%O8&y8sCCQH<%f2togVwW&{M;PR!a(ZT_A+jVAbf{@5kL zB@Z(hb$3U{T_}SKA_CoQVU-;j>2J=L#lZ~aQCFg-d<9rzs$_gO&d5N6eFSc z1ml8)P*FSi+k@!^M9nDWR5e@ATD8oxtDu=36Iv2!;dZzidIS(PCtEuXAtlBb1;H%Z zwnC^Ek*D)EX4#Q>R$$WA2sxC_t(!!6Tr?C#@{3}n{<^o;9id1RA&-Pig1e-2B1XpG zliNjgmd3c&%A}s>qf{_j#!Z`fu0xIwm4L0)OF=u(OEmp;bLCIaZX$&J_^Z%4Sq4GZ zPn6sV_#+6pJmDN_lx@1;Zw6Md_p0w9h6mHtzpuIEwNn>OnuRSC2=>fP^Hqgc)xu^4 z<3!s`cORHJh#?!nKI`Et7{3C27+EuH)Gw1f)aoP|B3y?fuVfvpYYmmukx0ya-)TQX zR{ggy5cNf4X|g)nl#jC9p>7|09_S7>1D2GTRBUTW zAkQ=JMRogZqG#v;^=11O6@rPPwvJkr{bW-Qg8`q8GoD#K`&Y+S#%&B>SGRL>;ZunM@49!}Uy zN|bBCJ%sO;@3wl0>0gbl3L@1^O60ONObz8ZI7nder>(udj-jt`;yj^nTQ$L9`OU9W zX4alF#$|GiR47%x@s&LV>2Sz2R6?;2R~5k6V>)nz!o_*1Y!$p>BC5&?hJg_MiE6UBy>RkVZj`9UWbRkN-Hk!S`=BS3t3uyX6)7SF#)71*}`~Ogz z1rap5H6~dhBJ83;q-Y<5V35C2&F^JI-it(=5D#v!fAi9p#UwV~2tZQI+W(Dv?1t9? zfh*xpxxO{-(VGB>!Q&0%^YW_F!@aZS#ucP|YaD#>wd1Fv&Z*SR&mc;asi}1G) z_H>`!akh-Zxq9#io(7%;a$)w+{QH)Y$?UK1Dt^4)up!Szcxnu}kn$0afcfJL#IL+S z5gF_Y30j;{lNrG6m~$Ay?)*V9fZuU@3=kd40=LhazjFrau>(Y>SJNtOz>8x_X-BlA zIpl{i>OarVGj1v(4?^1`R}aQB&WCRQzS~;7R{tDZG=HhgrW@B`W|#cdyj%YBky)P= zpxuOZkW>S6%q7U{VsB#G(^FMsH5QuGXhb(sY+!-R8Bmv6Sx3WzSW<1MPPN1!&PurYky(@`bP9tz z52}LH9Q?+FF5jR6-;|+GVdRA!qtd;}*-h&iIw3Tq3qF9sDIb1FFxGbo&fbG5n8$3F zyY&PWL{ys^dTO}oZ#@sIX^BKW*bon=;te9j5k+T%wJ zNJtoN1~YVj4~YRrlZl)b&kJqp+Z`DqT!la$x&&IxgOQw#yZd-nBP3!7FijBXD|IsU8Zl^ zc6?MKpJQ+7ka|tZQLfchD$PD|;K(9FiLE|eUZX#EZxhG!S-63C$jWX1Yd!6-Yxi-u zjULIr|0-Q%D9jz}IF~S%>0(jOqZ(Ln<$9PxiySr&2Oic7vb<8q=46)Ln%Z|<*z5&> z3f~Zw@m;vR(bESB<=Jqkxn(=#hQw42l(7)h`vMQQTttz9XW6^|^8EK7qhju4r_c*b zJIi`)MB$w@9epwdIfnEBR+?~);yd6C(LeMC& zn&&N*?-g&BBJcV;8&UoZi4Lmxcj16ojlxR~zMrf=O_^i1wGb9X-0@6_rpjPYemIin zmJb+;lHe;Yp=8G)Q(L1bzH*}I>}uAqhj4;g)PlvD9_e_ScR{Ipq|$8NvAvLD8MYr}xl=bU~)f%B3E>r3Bu9_t|ThF3C5~BdOve zEbk^r&r#PT&?^V1cb{72yEWH}TXEE}w>t!cY~rA+hNOTK8FAtIEoszp!qqptS&;r$ zaYV-NX96-h$6aR@1xz6_E0^N49mU)-v#bwtGJm)ibygzJ8!7|WIrcb`$XH~^!a#s& z{Db-0IOTFq#9!^j!n_F}#Z_nX{YzBK8XLPVmc&X`fT7!@$U-@2KM9soGbmOSAmqV z{nr$L^MBo_u^Joyf0E^=eo{Rt0{{e$IFA(#*kP@SQd6lWT2-#>` zP1)7_@IO!9lk>Zt?#CU?cuhiLF&)+XEM9B)cS(gvQT!X3`wL*{fArTS;Ak`J<84du zALKPz4}3nlG8Fo^MH0L|oK2-4xIY!~Oux~1sw!+It)&D3p;+N8AgqKI`ld6v71wy8I!eP0o~=RVcFQR2Gr(eP_JbSytoQ$Yt}l*4r@A8Me94y z8cTDWhqlq^qoAhbOzGBXv^Wa4vUz$(7B!mX`T=x_ueKRRDfg&Uc-e1+z4x$jyW_Pm zp?U;-R#xt^Z8Ev~`m`iL4*c#65Nn)q#=Y0l1AuD&+{|8-Gsij3LUZXpM0Bx0u7WWm zH|%yE@-#XEph2}-$-thl+S;__ciBxSSzHveP%~v}5I%u!z_l_KoW{KRx2=eB33umE zIYFtu^5=wGU`Jab8#}cnYry@9p5UE#U|VVvx_4l49JQ;jQdp(uw=$^A$EA$LM%vmE zvdEOaIcp5qX8wX{mYf0;#51~imYYPn4=k&#DsKTxo{_Mg*;S495?OBY?#gv=edYC* z^O@-sd-qa+U24xvcbL0@C7_6o!$`)sVr-jSJE4XQUQ$?L7}2(}Eixqv;L8AdJAVqc zq}RPgpnDb@E_;?6K58r3h4-!4rT4Ab#rLHLX?eMOfluJk=3i1@Gt1i#iA=O`M0@x! z(HtJP9BMHXEzuD93m|B&woj0g6T?f#^)>J>|I4C5?Gam>n9!8CT%~aT;=oco5d6U8 zMXl(=W;$ND_8+DD*?|5bJ!;8ebESXMUKBAf7YBwNVJibGaJ*(2G`F%wx)grqVPjudiaq^Kl&g$8A2 zWMxMr@_$c}d+;_B`#kUX-t|4VKH&_f^^EP0&=DPLW)H)UzBG%%Tra*5 z%$kyZe3I&S#gfie^z5)!twG={3Cuh)FdeA!Kj<-9** zvT*5%Tb`|QbE!iW-XcOuy39>D3oe6x{>&<#E$o8Ac|j)wq#kQzz|ATd=Z0K!p2$QE zPu?jL8Lb^y3_CQE{*}sTDe!2!dtlFjq&YLY@2#4>XS`}v#PLrpvc4*@q^O{mmnr5D zmyJq~t?8>FWU5vZdE(%4cuZuao0GNjp3~Dt*SLaxI#g_u>hu@k&9Ho*#CZP~lFJHj z(e!SYlLigyc?&5-YxlE{uuk$9b&l6d`uIlpg_z15dPo*iU&|Khx2*A5Fp;8iK_bdP z?T6|^7@lcx2j0T@x>X7|kuuBSB7<^zeY~R~4McconTxA2flHC0_jFxmSTv-~?zVT| zG_|yDqa9lkF*B6_{j=T>=M8r<0s;@z#h)3BQ4NLl@`Xr__o7;~M&dL3J8fP&zLfDfy z);ckcTev{@OUlZ`bCo(-3? z1u1xD`PKgSg?RqeVVsF<1SLF;XYA@Bsa&cY!I48ZJn1V<3d!?s=St?TLo zC0cNr`qD*M#s6f~X>SCNVkva^9A2ZP>CoJ9bvgXe_c}WdX-)pHM5m7O zrHt#g$F0AO+nGA;7dSJ?)|Mo~cf{z2L)Rz!`fpi73Zv)H=a5K)*$5sf_IZypi($P5 zsPwUc4~P-J1@^3C6-r9{V-u0Z&Sl7vNfmuMY4yy*cL>_)BmQF!8Om9Dej%cHxbIzA zhtV0d{=%cr?;bpBPjt@4w=#<>k5ee=TiWAXM2~tUGfm z$s&!Dm0R^V$}fOR*B^kGaipi~rx~A2cS0;t&khV1a4u38*XRUP~f za!rZMtay8bsLt6yFYl@>-y^31(*P!L^^s@mslZy(SMsv9bVoX`O#yBgEcjCmGpyc* zeH$Dw6vB5P*;jor+JOX@;6K#+xc)Z9B8M=x2a@Wx-{snPGpRmOC$zpsqW*JCh@M2Y z#K+M(>=#d^>Of9C`))h<=Bsy)6zaMJ&x-t%&+UcpLjV`jo4R2025 zXaG8EA!0lQa)|dx-@{O)qP6`$rhCkoQqZ`^SW8g-kOwrwsK8 z3ms*AIcyj}-1x&A&vSq{r=QMyp3CHdWH35!sad#!Sm>^|-|afB+Q;|Iq@LFgqIp#Z zD1%H+3I?6RGnk&IFo|u+E0dCxXz4yI^1i!QTu7uvIEH>i3rR{srcST`LIRwdV1P;W z+%AN1NIf@xxvVLiSX`8ILA8MzNqE&7>%jMzGt9wm78bo9<;h*W84i29^w!>V>{N+S zd`5Zmz^G;f=icvoOZfK5#1ctx*~UwD=ab4DGQXehQ!XYnak*dee%YN$_ZPL%KZuz$ zD;$PpT;HM^$KwtQm@7uvT`i6>Hae1CoRVM2)NL<2-k2PiX=eAx+-6j#JI?M}(tuBW zkF%jjLR)O`gI2fcPBxF^HeI|DWwQWHVR!;;{BXXHskxh8F@BMDn`oEi-NHt;CLymW z=KSv5)3dyzec0T5B*`g-MQ<;gz=nIWKUi9ko<|4I(-E0k$QncH>E4l z**1w&#={&zv4Tvhgz#c29`m|;lU-jmaXFMC11 z*dlXDMEOG>VoLMc>!rApwOu2prKSi*!w%`yzGmS+k(zm*CsLK*wv{S_0WX^8A-rKy zbk^Gf_92^7iB_uUF)EE+ET4d|X|>d&mdN?x@vxKAQk`O+r4Qdu>XGy(a(19g;=jU} zFX{O*_NG>!$@jh!U369Lnc+D~qch3uT+_Amyi}*k#LAAwh}k8IPK5a-WZ81ufD>l> z$4cF}GSz>ce`3FAic}6W4Z7m9KGO?(eWqi@L|5Hq0@L|&2flN1PVl}XgQ2q*_n2s3 zt5KtowNkTYB5b;SVuoXA@i5irXO)A&%7?V`1@HGCB&)Wgk+l|^XXChq;u(nyPB}b3 zY>m5jkxpZgi)zfbgv&ec4Zqdvm+D<?Im*mXweS9H+V>)zF#Zp3)bhl$PbISY{5=_z!8&*Jv~NYtI-g!>fDs zmvL5O^U%!^VaKA9gvKw|5?-jk>~%CVGvctKmP$kpnpfN{D8@X*Aazi$txfa%vd-|E z>kYmV66W!lNekJPom29LdZ%(I+ZLZYTXzTg*to~m?7vp%{V<~>H+2}PQ?PPAq`36R z<%wR8v6UkS>Wt#hzGk#44W<%9S=nBfB);6clKwnxY}T*w21Qc3_?IJ@4gYzC7s;WP zVQNI(M=S=JT#xsZy7G`cR(BP9*je0bfeN8JN5~zY(DDs0t{LpHOIbN);?T-69Pf3R zSNe*&p2%AwXHL>__g+xd4Hlc_vu<25H?(`nafS%)3UPP7_4;gk-9ckt8SJRTv5v0M z_Hww`qPudL?ajIR&X*;$y-`<)6dxx1U~5eGS13CB!lX;3w7n&lDDiArbAhSycd}+b zya_3p@A`$kQy;|NJZ~s44Hqo7Hwt}X86NK=(ey>lgWTtGL6k@Gy;PbO!M%1~Wcn2k zUFP|*5d>t-X*RU8g%>|(wwj*~#l4z^Aatf^DWd1Wj#Q*AY0D^V@sC`M zjJc6qXu0I7Y*2;;gGu!plAFzG=J;1%eIOdn zQA>J&e05UN*7I5@yRhK|lbBSfJ+5Uq;!&HV@xfPZrgD}kE*1DSq^=%{o%|LChhl#0 zlMb<^a6ixzpd{kNZr|3jTGeEzuo}-eLT-)Q$#b{!vKx8Tg}swCni>{#%vDY$Ww$84 zew3c9BBovqb}_&BRo#^!G(1Eg((BScRZ}C)Oz?y`T5wOrv);)b^4XR8 zhJo7+<^7)qB>I;46!GySzdneZ>n_E1oWZY;kf94#)s)kWjuJN1c+wbVoNQcmnv}{> zN0pF+Sl3E}UQ$}slSZeLJrwT>Sr}#V(dVaezCQl2|4LN`7L7v&siYR|r7M(*JYfR$ zst3=YaDw$FSc{g}KHO&QiKxuhEzF{f%RJLKe3p*7=oo`WNP)M(9X1zIQPP0XHhY3c znrP{$4#Ol$A0s|4S7Gx2L23dv*Gv2o;h((XVn+9+$qvm}s%zi6nI-_s6?mG! zj{DV;qesJb&owKeEK?=J>UcAlYckA7Sl+I&IN=yasrZOkejir*kE@SN`fk<8Fgx*$ zy&fE6?}G)d_N`){P~U@1jRVA|2*69)KSe_}!~?+`Yb{Y=O~_+@!j<&oVQQMnhoIRU zA0CyF1OFfkK44n*JD~!2!SCPM;PRSk%1XL=0&rz00wxPs&-_eapJy#$h!eqY%nS0{ z!aGg58JIJPF3_ci%n)QSVpa2H`vIe$RD43;#IRfDV&Ibit z+?>HW4{2wOfC6Fw)}4x}i1maDxcE1qi@BS*qcxD2gE@h3#4cgU*D-&3z7D|tVZWt= z-Cy2+*Cm@P4GN_TPUtaVyVesbVDazF@)j8VJ4>XZv!f%}&eO1SvIgr}4`A*3#vat< z_MoByL(qW6L7SFZ#|Gc1fFN)L2PxY+{B8tJp+pxRyz*87)vXR}*=&ahXjBlQKguuf zX6x<<6fQulE^C*KH8~W%ptpaC0l?b=_{~*U4?5Vt;dgM4t_{&UZ1C2j?b>b+5}{IF_CUyvz-@QZPMlJ)r_tS$9kH%RPv#2_nMb zRLj5;chJ72*U`Z@Dqt4$@_+k$%|8m(HqLG!qT4P^DdfvGf&){gKnGCX#H0!;W=AGP zbA&Z`-__a)VTS}kKFjWGk z%|>yE?t*EJ!qeQ%dPk$;xIQ+P0;()PCBDgjJm6Buj{f^awNoVx+9<|lg3%-$G(*f) zll6oOkN|yamn1uyl2*N-lnqRI1cvs_JxLTeahEK=THV$Sz*gQhKNb*p0fNoda#-&F zB-qJgW^g}!TtM|0bS2QZekW7_tKu%GcJ!4?lObt0z_$mZ4rbQ0o=^curCs3bJK6sq z9fu-aW-l#>z~ca(B;4yv;2RZ?tGYAU)^)Kz{L|4oPj zdOf_?de|#yS)p2v8-N||+XL=O*%3+y)oI(HbM)Ds?q8~HPzIP(vs*G`iddbWq}! z(2!VjP&{Z1w+%eUq^oqT|Dz}?C?_cuFbIhy@Hlls4PVE#kL z%+b)q8t~t$qWrU}o1>w6dSEU{WQ11MaYRHV`^W006GEHNkKbo3<`>slS- z^Iau?J5(A*RcG;?9caykA`<#qy1~O zV;;PYMn6SI$q}ds#zKhlt{2DkLyA|tPj@5nHw|TfoB{R9AOtjRH|~!gjc7>@`h6hQ zNQ|Ch4lR}rT_GI4eQoy|sMheUuhTnv@_rRPV^^6SNCY zJt~}LH52Y+RK{G^aZh@qG*^+5XM={Yu0CS=<}foB$I}fd5f&atxdLYMbAT-oGoKoE zEX@l(|ILgqD&rTwS4@T(du@BzN3(}du%3WCtJ*e1WJ5HWPNihA7O65R=Zp&IHPQn{ zTJ{$GYURp`Lr$UQ$ZDoj)1f(fN-I+C0)PVej&x_8WZUodh~2t5 z^<=jtVQnpoH>x5ncT0H=^`9-~oCmK=MD#4qnx+7-E-_n^0{2wjL2YV;WK(U;%aCN} zTPh334F$MTbxR7|7mEtX3alSAz|G)I+eFvQnY}XldO7I7$ z2-ZeSVckL<)N1tQ)M6@8uW;`pybJ4+Zf4&;=27ShUds^TB8DN4y^x=7xslL*1%HX_ zT(iSMx?g}!7jTEjX@&lI{{ifXnD}tWA8x4A3#o?GX9GMQHc-%WBBl|UlS|HYNH}JU z?I48Qizg+VWgSZ#zW<;tMruWI@~tW~X_GT(Me0(X0+ag8b-P6vA(1q165LJLl%zIl z?Ef?_&y7e?U@PK^nTSGu!90^0wjPY}`1@cng< z8p@n!$bcZvs3dwYo!t+cpq=9n`6Gi|V&v32g3zJV>ELG|eijj@>UQ8n)?`HPYai20W!}g}CSvAyisSPm0W|p?*Zq_r(%nCY8@}OXs2pS4# zI*)S^UFi`&zltazAxB2B_Gt7iX?Y25?B#w+-*y#dJIH(fIA<(GUhfiupc!IVAu&vF zg3#yzI2SrRpMSxpF*`0Ngul=!@E0Li|35w|ING^;2)a0%18kiwj18Ub{sSbEm38fq z1yOlHl7;{l4yv_FQZ`n><+LwoaKk|cGBRNnN;XDstie!~t5 z#ZWz9*3qvR2XkNZYI0db?t^(lG-Q8*4Jd6Q44rT71}NCQ2nryz(Btr|?2oa(J1`cn z`=-|7k;Q^9=GaCmyu(!&8QJRv=P5M#yLAL|6t%0+)fBn2AnNJg%86562VaB+9869& zfKkJa)8)BQb}^_r0pA1u)W$O`Y~Lenzyv>;CQ_qcG5Z_x^0&CP8G*;*CSy7tBVt|X zt}4Ub&av;8$mQk7?-2%zmOI4Ih72_?WgCq|eKgY~1$)6q+??Qk1DCXcQ)yCix5h#g z4+z7=Vn%$srNO52mlyjlwxO^ThKBz@(B8WGT`@!?Jhu^-9P1-ptx_hfbCseTj{&h}=7o5m0k)+Xx7D&2Vh zXAY*n|A~oM|4%rftd%$BM_6Pd7YVSA4iSzp_^N|raz6ODulPeY4tHN5j$0K9Y4=_~ z)5Wy%A)jp0c+415T7Q#6TZsvYF`adD%0w9Bl2Ip`4nc7h{42YCdZn};GMG+abcIR0 z+z0qSe?+~R5xbD^KtQ;-KtM$Q{Q~>PCzP!TWq`Wu@s-oq!GawPuO?AzaAVX9nLRvg z0P`z82q=Iw2tAw@bDiW;LQ7-vPeX(M#!~eD43{j*F<;h#Tvp?i?nMY1l-xxzoyGi8 zS7x(hY@=*uvu#GsX*~Jo*1B-TqL>Tx$t3sJ`RDiZ_cibBtDVmo3y^DgBsg-bp#dht zV(qiVs<+rrhVdh`wl^3qKC2y!TWM_HRsVoYaK2D|rkjeFPHSJ;xsP^h-+^8{chvzq z%NIHj*%uoS!;hGN?V;<@!|l{bf|HlP0RBOO(W6+vy(ox&e=g>W@<+P$S7%6hcjZ0< z><8JG)PTD4M^ix6OD5q$ZhUD>4fc!nhc4Y0eht6>Y@bU zmLTGy0vLkAK|#eZx+rXpV>6;v^fGXE^CH-tJc zmRq+7xG6o>(>s}bX=vW3D52ec1U(ZUk;BEp2^+#cz4vt zSe}XptaaZGghCACN5JJ^?JUHI1t^SVr`J&d_T$bcou}Q^hyiZ;ca^Um>*x4Nk?)|a zG2)e+ndGq9E%aKORO9KVF|T@a>AUrPhfwR%6uRQS9k!gzc(}9irHXyl5kc_2QtGAV7-T z+}cdnDY2687mXFd$5-(sHg|1daU)2Bdor`|(jh6iG{-)1q_;6?uj!3+&2fLlT~53- zMCtxe{wjPX}Ob$h2R9#lbdl0*UM_FN^C4C-sf3ZMoOAuq>-k+&K%!%EYYHMOTN~TB z8h5Ldln5sx_H3FoHrsaR`sGaGoanU7+hXf<*&v4>1G-8v;nMChKkZnVV#Q_LB{FXS ziG89d+p+9(ZVlc1+iVQy{*5{)+_JMF$Dr+MWjyO@Irs}CYizTI5puId;kL>fM6T(3 zat^8C6u0Ck1cUR%D|A<;uT&cM%DAXq87C~FJsgGMKa_FN#bq2+u%B!_dKbw7csI=V z-PtpPOv<q}F zS)14&NI3JzYKX?>aIs;lf)TfO3W;n+He)p5YGpQ;XxtY_ixQr7%nFT0Cs28c3~^`d zgzu42up|`IaAnkM;*)A~jUI%XMnD_u4rZwwdyb0VKbq@u?!7aQCP@t|O!1uJ8QmAS zPoX9{rYaK~LTk%3|5mPHhXV<}HSt4SG`E!2jk0-C6%B4IoZlIrbf92btI zCaKuXl=W0C`esGOP@Mv~A!Bm6HYEMqjC`?l1DeW&(2&E%R>yTykCk*2B`IcI{@l^| z8E%@IJt&TIDxfFhN_3ja(PmnPFEwpn{b`A z`m$!H=ek)46OXllp+}w6g&TscifgnxN^T{~JEn{A*rv$G9KmEqWt&Ab%5bQ*wbLJ+ zr==4do+}I6a37u_wA#L~9+K6jL)lya!;eMg5;r6U>@lHmLb(dOah&UuPIjc?nCMZ)6b+b4Oel?vcE5Q4$Jt71WOM$^`oPpzo_u; zu{j5ys?ENRG`ZE}RaQpN;4M`j@wA|C?oOYYa;Jja?j2?V@ zM97=sn3AoB_>P&lR zWdSgBJUvibzUJhyU2YE<2Q8t=rC`DslFOn^MQvCquhN~bFj?HMNn!4*F?dMkmM)## z^$AL9OuCUDmnhk4ZG~g@t}Im2okt9RDY9Q4dlt~Tzvhtbmp8aE8;@tupgh-_O-__) zuYH^YFO8-5eG_DE2!~ZSE1lLu9x-$?i*oBP!}0jlk4cy5^Q;{3E#^`3b~Su_bugsj zlernD@6h~-SUxz4fO+VEwbq+_`W{#bG{UOrU;H)z%W0r-mny1sm#O@gvwE72c^im)UrJnQgcB_HxILh!9fPQ);whe*(eIUjA(t{8iI(?NY<5^SGOr;vrcKpedfTu zWCTHMK16<@(tI%`NxN3xW6nKX{JW=77{~yR$t1$xwKUm7UJmOrnI4Z zajmwO&zZ8PhJ6FNRjID+@QZ8fz%%f2c{Xh*BWDIK zXrFxswPdd;(i}fLsNVb(sx-hMJ>IQ0QvH^z3= zc;TX|YE>HpO6-C5=g{+l3U6fF`AXJM6@kcoWLQXxiNiXab#!P8ozeR^oy#PfdS#aj zUDKKNx>5&v%k*OBF;-)X5Afpd60K{FTH@1|)>M!!F)jb))f&{UY-rcR>h z`~9|W#a`Yw7fD~{3`rktJC|L46-(sRaa~hM-d#KSG6@_*&+pnNYQ2JSy@BNg_Tx7< zB-vhG+{d^*zIH!;2M7O`_S{?EKffQ02;N>=2!3JqQX(M_Aj#}dCfdb?yGH%tk^_Zf zAtZ5!rnq4(WSd!_GfuPp4uDd2(8%>)Iu6z=XjRQLi2_RBg97~ zr$zf>FNkUG3~bp6#hl^3HSA2*SS-DT_QkX#QNcG2?8&Cm6Sj#}yaqEhjq1GabS)ZwBhcKc;52~Qc*Z@=jRjfqZO1%y?*D(iB&EE z-Aln~CD}?DqVGGB``Q@F-TY|Fj7)4D28@Z-@a-A4(KC*}W4*2l?E>!wviGFcB*Dc3z50hH^i0Y`j zip{Em#(a42NnOEvkU+6SfAkEzO$ z*j*3sOP4y2W@t7)nbi9Dcj|9Bw}z)VzKuAx4<&3`!gMhuW5&4%F@_!ZKBoaBHYwcn3WcL^0l zkdkY#l8~$5UazRWOJo32=kA|tKs!Y_vX=+xrA3Mwd45^vZe02+dI_r|rmO-`>l0$i zEB%YFf8ecv=Q@YPntwR)df$>p+zI@!1-aj13HMYz5$QWWp$U&Z(I?C5rYl8S=m|d!*(Y&`gzl zu00=P^fRg?$GE2+$)wr(ohep`G%yKT(qdGmR!M45W`~K4bC@YwX{J;T@dq=$9o>;L zz%NIUoFhZxHIjtR1kdw5V7u=4{!3oQc;za?0UQVj5f%uD<=^`&>TYc9;$-0p5VNob z2pSvzby?QX*3j%fJx*5BcET~k^5xT{iQin-qP*nWQ9THOA69^wDN5utzTj#~upjf}CtShX9;wdXE35EVlzWqIGJ z)io1?vG_sea+iQjU%m@q)4(=eS5zC1h|!bCE~d9gvl{7)!IScau*OTR`)!Mhr`mdX zlhmcf-Ms-t;DYx9o2z=q68Nm{ zOF;j&-eqWvD}_5X8`^t48wcrR%*&RycEe!J5nJguNo~cP6)1|!4@Jb2YL6IYdyrH8 zI$W1D+$LRa4*EC=4Cr)=0Qap5g}M^+jyvlDE}G8-wsVQYX&UXR#=~{XZLTPY`=3=N zkvaUS+4ofuBn|356>5pTPX|r)^QG(R2d$TX>Krwf&QVgVCM9zP64l%Z8B=2RYP%{E zaKc@qdtK`R({$|K`t5>0?KorZI1)6`9@|#O>v1WK@3bbLFtGM4gd98X0(-9{W{NiN zIuG0D%0l5WhXSRNbfROzH6w*YO&2Xpx5amm%+T4$qtvPDK+eUjfs$g@<`DBwNH1(33NhDKwO*I9E z$bW{D7h4@U~&K4klFtk`+Smzy>$vNph6hQsYQ1QF(- zHK>f)>|MT%=q)(U-3br5R4KIE!FeeTP`{-^wpgKJzcOqD?!&-6Yf7fd<^40T$r z{@91>s^KAH@mw(72{v#n4rzh?z_qh-AL;FAt==sT(BFv)(FXSoKd)RMA40`^)3^+Z zwdPe9j*t}}%!Fk@58lX}s`NX-7M;>k)w7j1`*~g_dAMDLsOq`@C>D(lreX%!c_OjX zTP$xDO*C|S27Hd)6?;6;Y`P3$%YFG)9y2H0Yuw;6Z2{^y2YvKP`V&OVi;L`j{L;jL zvz-omEQby(t)f?-HssRfTDYnS`=UG{>1Y)Dh(Xb>WU++>XOoF@TR;-#<1E+1AqPdk=H6)VQ32z zLdHM3uv~8{(>v|*O>k2VTW}=fw~%fuNfyf6FMaEXzdHB?tnHs6%)R(k_^``|IN|L# zV&QQG*x~n}a?;|la|TQD383!6WOfCv9V@-(g`ab3{CgpIjQ zGyCjpiIaK${m-Zd;m*k+7;?~M6)Wqb>yI*k`=@zOr%NjIs(C?BUqCq8^ zsi_)Bk)kyU`NL<6nholj+3Xs*E%vZ2H<};VoFCvMFLYwFg-gi8C%2@0gH#_lU>~8E z?>!v9-YFw6r=Z{xMI59a3J6_y8&}4UeEr?9w($B){={R9reR;r4Jgl?G)eMv=EOsc zckWsS;fuDu;l?Dgzgyhj^H>RMJs^*kzUfB#Ax}fqmj?Eb#G1W$J(4a)qfI(k=2*_Y zqr3?H*#`c8owZQ>48MUl@A(yQxuXBM2|bdy`x=bcfHc~8b9#odFy|NGMC(oMC%C+$ zi;L=xaJ%=;6Qf)kX-netDG|g#BZrnfdTm79e(Px7oy)wLHNB^EUMI7snGBJIuq*RP z@Xv@1TIRW_^S82~__wm~U(}t&|5uS))d}DzVP^x7v9q&svHy>{v$D24wjk=4SiJ7i zqf#YhQ?sQusP?MXrRx0PczL)ABq5Z%NibA3eTRvr^@n;Fsio!I2;YM^8}EP;&7WT# zqivIJ-A+dn6W9FwzQ7v&<$;P5qwe`TR5_AiRFDRGVmdG3h+?&byKRASKwXHQiegIU zvi;If(y)ozZ%=Q6)cR|q)pkV>bAocyDX#Om&LQ?^D;#XBhNC;^+80{v1k1(4X1RWKo4Onb+)A zp&OGpq39Ss9Do68%xbC+SH>N@bhr?aF^3ARMK)^mWxfuvt|?ucl0$sf){gT9_b~^# z3>QnE)-@zE%xH=ax{R1+8?7wHJFQhqx1xirV(lZN0HU=>7ODhQ5k^5BK973IumdDP z(oUtiC^Ya#Q@9^~vNuH)*L|F$!0eySLZ_2FYGn%S71MQAFrHK4i#UwxjM0gxL;pC#^nGA?B0S zjI>+f^}Ik10y+Dkm{%iS3&XUVZ;GCHpJ5Re31~x@7X68v;(n<6>>q?g=^VldiKw#@ zEOQ_*7zX;nDQmDM597=8yqlznk7 z+#rTK!TN>LKK0vPkO?^!tGYfh{PQwx2{$;;hXw+o#{4V)o@o7JnX3Pzzv6$kNc=~k zLIc7ZWf|+6KhEdwl_w5PEQknl2TTo9GE7ziZ{5ESq%({Nit}IqJ>FT2iz#C<-kH>9 zZ7#i0)@|N7p)q-r1L{;J^UC?UYp(10rKh8TRyy>yhJWXD>$&^W=lZ>SB=Othg$XEg z5FL%%z9nMPJzPhRIyIGwqaa@*F!II`tmbAv*|$^bO0Q~(jj|aJj5BP6N%o zi>Fh52P_qg$2UE^&NabtBe|(p{jB`_nxYv`c#kx>LN*OSN+N zU4?c;6AYnTgQjgGHWamUI~Jj|bO=J#gpsI+{P2#bjpt${i6FN0W?!+*Po|F(Ep~r^ znlCW6`~{P*dJn~2sE-28TWaVhPubr5OB6wFGHdSr{ylUzA%71gLT*B+enM2v-TrvO ztop}Gd0>sC_EpOG@@K2?m+wHVUHJ=ochwHJueUm~pZw7CElAsk!cgpuF&clLJlcoM z5RfmuLPJGOQ&+|Qje(!|_U>laCSIu5Go16&6C`MR%qhi#y^MTR$a|FuE7KaW!jdVu zQc6y3$b-fjA|zT|iyLgCtE)?+*{ez$14G@qDry0u%fYe=m_L9 zcpCG?q=Z0|3N5rQ75C6%&qtH`V%gd}#f)a{GqGaN!;vg5_;5m_q=-%TK(QnPrSGBM zJR)n3VvZ+adg)`v(iogiMOEgsJRqsAT%F)$7q%>N z+>ypdC#5P+#5I)8tD%Jz_C$CkQ4(v+;XO+*-@Vqfr%y4;NXBbf)IKJp+YrDNXQtxD zPjcXDE`uD{H50-$)3Jxd>X|xN$u3~#ft_j`y+MY-5bs>?@)We6Dr$y%FUB(3ui3I# z7^>}aXe=hA%0I;(8>2ca-1`OXuRv5Kv8h?&2rUu>D9D7L@V+srE z;`vC7L`JG;GbZ`e$0uDdeHVMFNI+5qBQG04|Ejy-g zBlav6v%&NUA^JNO?bO@ZQP|(AT!lFEgBu*fg)=wOA5wiaY#-n~WK#|S`TM7(g1I)Y z{MElhws)Vgzx?^BUlK$3_Zei$(_xyl<)dBB_p!esdMsYJzw(HJx!JOYS=cmMrTh5V zK48AlHI8<>h)vH(Dt}CkO2SPKUCu>*r(ZT(MEJC`EoDeyIjAiZ z4!$#Bv;#Ha|50x!E~2$H@qVM*{HX?6=U`;C_*DY9J?+_ zE_1(oZky$GE>%urwl$tN$r2Q;P6h=-(#J>KqL@4-5)GJp?Lnl!QHTV56UmG?h?t2t z8N0+xSbWmtk1G4%6cSek>wX?&<^~ckAjopL$THKk$l^NQSZr`^P^wN!3f97?2^9l& zo!!HDu5GNryHQMMV&*B02#4$-Kd86@R8@jPjIwC0qR`5yN~0wFF<)(m`Oe--meLR- zQ^9g0Oe9t;I$nX*0sl)jqI6z_x7yg_iIO2oCo`RV(;7kceK2{MG}=Z%q=5WqSafGh zp!GmTD`*RiQDP@S%N*1(9eILhgEc~3nujB!gK^;UZ?|@f%BqT7`F*;dx;_lgxCloE zv)sDk$CT1t^!Ia2yo(vQvLn$!E<}s<-iI>wtXvs#cScn-lpVpte^S&<NYtNP%9=Z+{&Er+rD=2JmitU_vutwn0S4Po2dU$b)6jiBdJ_5VEwz9fT28%;c zk9W8e_B3!WT3Yoz&l)@3uIZ7)GxE z4Xl;;y6~Y|bC|KGj+Bzc?zL66dWH|!>z2pjQuj2bzisLrIDXD?MOOKv{oZumqO&Tt z(~hW<7OR@y^~R0RadKcc}NKI%CiV=eeh%``Vo-RnrvWK(sOydLoK zU$2g-d)ye45;H0P3=L^>a&{%W>(CZNGqYdWEauKGS;tJg%qiCob8E(^&Ltqv)pJgJ z&&ALyxTw~=UZJ1wWa6FTSiq|!=(n^Uh6myUWeNhp4XN3+{UOy#Ftu8-K`^nJ>flFd zrY{FgM8K$1LqQ75sR1Gihk}T(Mj6_MzTTVM8c=aWC@_Nbl|mSZWE8KFmDj4&kDogj zSUoIBdvUaPo-Qjs?4qPLIBoTo}E0mu%O#i zjm2g)0K=|B!>PrQU6C)*{U!S_iH;eR(+_BcTepYExFxn8!O{tLGH>!>zj_IE7r)%$ z?Kj)U{L~DD5_u&9xkDs~GuDvcMA#7<3~M4F-;4 zX{_?jDjL0nedG#Aj2fZRjuBw*dG&M}z$K~y`=~0SC{f_vKrGD^_#{2q!p2xg1IciZ z;6wviQw)Z0Hz~1MKn_K-%}1{7iCGmZyCb`R?p&CxP^!0b{>qsgub#@fpls6(4F0Qt6oWd-ZU(qRseeZ6RRT3Iw%y-mKV?})8V^t>+XKZ0#Gsb%{m&C+Up z{YiPA(cio~45i}`!<+#^hh^P^Ax*|;Uv#Z_fvLAL!yjHjeiP+X&0K}j`c_F-kh6dt(*W7~Cd0 z!!{rP?PE89LfP-8j=XH)`|5V2_sAlez76p+Ax{`9SgVx3_Iv1IRK>q9QHADt#*Y!6r?w zJ5bTiaP7*l{|Znqg@Z$x7oV~vxDJT69J;^p?pH^8117H{G^OIb5#ko3+BjY7nwHaj zt0PiK=(W2l&_CZ%!Nyr& zk;xb^^2gea?J8Y4B6V6KpAUV5{4>)%zR++g|I2XK{|fQHXS$OA+0XV5hAa9vXWGvQ z8}dDIdW4G939a{NblX`04I-%Upx46uQ;Pe{nJ*K9pf?nmI~fadH1*^4-g}b(2>rzC z#1j(IH=l-#O&&7wl>AtIDv5H{5F=QBj8)rADX4*jNMqATF)3Zm41sst%ZI71^f^ed z@k4X+T)1B&GpQ(qLaBD_CLb|`4ZHuwn4wK-^(iT`l{D(B;7B=Cz+M5OEeKs_+(z2v za^=DLy4UYtJk74ad|CLLJpGCAUwdln3G6T`G}oWeH@cHs@7q zZ;{{rJ#XqSrPu5YnVZ%rkVhU*S)AM6sn6cq+}oTU@7p!q;08Ef&9K@xt*``1yTZ(v z%rc{K^2CvW;4I;wa+Z|j@gjog^LHj>_EJal#C3qQ_`di)StH~kQa)IQfO-k@l#<%^?z_se2)nkaRm+p zPBWe7uN31~FEskXR3)9XAlHgFJv&e3NX2J-cgVY#7?_b>+!ly6f_$nIfQU#xA z)62KU z9-k;5Ns8x>h4*lKw`SPB)%zGPMKSuj^&x*-(Xe}F9l#p6%3I3~#%Xiyjwj*-4 z0~Yjnt=EbfR5^w@kvUvtQg^rxvBzS5v7#6s+?%HBy3@SdU!}ZTW!kVhx|rdZMRylS zPGddO{_KC~f7)30WFCU)mud)b&HQbnKg_k(OrbtShyJUPo>I6flvXul0WOo zW2?G$1Uv2>>~5z@7{AQS`WcR|NK6bR_;sX1TdBR4HIPQ|DWOhW7ypB95P59D(C&M? zRyztK7nufK3Uj?YTb74wuIqBT@@h!Q(R7V6Hskn&_zYAT@5l$Z;abhWF*eh-9wum8 z_WpLonUYWAz1wt9i7`t!CUb`e%cm&*bV4YBo( z58L?ql-giN`#~)zhh5Di5A(0|5>v+e9az(x%FcH27o0(St?R>iBxiyBPNoJAbZVz- zS}tavhAJ0kgd+tZjT;&?Bc%%F3vsl#+)G2N?I|@T%6`h|7*kwkGqLte^qR*n0c>>{# z-gTbvExPb@9s2(0T|wq12+Oma8+`3o#BvN+W|Q7o0p`?NLu*jCe4%a&DjmuyCl!0} z)T$0ghCzsXXT$P*~yojBLuRMs-L)E+45g0MNcMtTz>~WZ3Eud|o zf=UioWFpEiNfFa|W_xpfdNm#~s<&6v75(lXw}-{(>=qfJ=7WlEcCAs3Z&jRxGctHA zZmsbixM5%p#!f2}I@{dw5xVdzM2kMSR-8{HvT~QixsE1tq#i1Sp~a*5#|QXg@VbV{ z+l52hbp+qNh+n~mP52NCG@b03k5R zC8cEEGUo2RP-wCS{xX60P~KP3;tdynQ8QG+Bh3&#P#3%$p-jg&JZP~`lZjy-ruMup zxin_e3%MS~+@&N_lp5}Miq9Jn3IW%TuVqgu%fG%ueu!E8J<+ktfppS?F!Jjabc>)f za}Xj8`o>RnXqxrq{a^B2;5Gyqcz=Hxx}X9ABK$AV{~wt6zuR!VRSui@DOl3E({%_z zg)oTn`%0kcqqzPOFmvo_sGCzBbx)~6PT^gT9~qPTAUb1!ALaXwua$Ad zN*U$e)koOD$L}5i{V;&xe4xqwp}C&HY3ai@nL%FV;VEbZrsX$}HXikZ+tp6y-s79L zADxR-ozw#3y)ed)bF32cl&ESj!S^4XVxAeOeEPf7FKw&SRz(G50>^h;7E2H>z+1oV zt^Aj6-1+U2j>#>`fjiS%D82LgZI~_o-o9-HYPu1HwnI>;xUt!d{OlCwqmM6^GNco* z*{HS`_iuLS$Q|%q`rM$pb3Jrm$H`wT^4+4E4ueEd7&{N2QcSYVU3V?;)u*R002cF3_eFPTkdWg8D0NlE3DW8Y&l zLU9lkf8tPHl}rp2GpuEgek$~~Vhi=KV?dlcPe|`3yW84AG4T| z?>>1gRzk%lb(s>@r8GOn<9X419ydKlrh;BfB~LXh?nQvf+c3Fs1c{h-jV`hlKR9C= zznFgMZ)QnZBBWp&3nQiCAWj4!wVxAN0zAT4Wfrklj?4Xq)D?F9+M^wdt}{`YHnBOp zbKaxDALj*|g~Ged`KrVnRM9=l$lNG$tOd97ux9ljHfr-X)pox68%w2U=(bcoe7TO5 zQI^7v~qkOC9lph+Umgo3Oo#A}sib7A3lAmsx47{b#ifMtPr{^E3FN@Dnx2o=3 zK0K0Zj(MT|1o^s4@8G-(#`O1a>UatC%i3UqR#H{Jp#9LOO{~JqZFQB^gNa3VYsxxP zdtyqba^lb`2!*C;yc5UR@9C(w$6Cs~x&IQ)Jv|mm?~<|Y9lLUGjBDjr+ivj;FV${& z)>i#Ph!dL&;DJbXQsWe)MV8f!(}a8LV4>AuA#*)RBRxvoWt2RP4d}d&MphE^Iit@s zQ=^7xY2XTYwqn<gekKI^&oubIG!&M(Ua%z=;PCjAK8WP*cFqgoJZzsP4M z8~$oUsx7G6u+aQmIpAc1J-dp=*ekVHLO=1t>wfADn^aA)&}=8++o`xr*lcWERK6-w zHDoIgG2LU4rZ0t-W@&_`b5B|mi&^~DTH&scMO|Iw1{g;c?D}>#m}vZrV=dchn8!2+ z+Qv8GTIZe{$2hfQAuSh6T+7fxb2uz0%n?+)-LzU-C<}5CX#k7CplPZW{u%53Y#e(1 zgo)6_A*#Y+z6NE-9Bf{3Ib1TSl+kG;W`d(aNY+)<5Vum3Zq+4a9Ms|}*jn0;WCC64Pc1Az`CY0=-k z$5a8Mp&njQt{&nuwl|_^xS}rh< z(#wu{IlD&m3s~${!pJ`S3NM_=xyK-}pyn&Oh^$|V(F+2YB!gTUyrPQIL|pi2e$ECE65#dDJO6vV9H15{cjs1lOB zC^?*8U0M?f<}yYxI}B({nHh1AN$&YvA!~An1b64q-x7xe_c+wwLED2GHOk=SAL!pI zhb^yo3%{$IVx@YHbE!U@lDE;EKLWR4BEXg&hQdUmZ;zv#9@HatIge>B;(iwog{ZTBnlla=sVbuf&Zl_nR7(b-rg z9Cs#mA_^>qksL|9ffWG?>_CfSGLl?|b9Bx;%i*&nSc>sV96|2Ns!^cD!)+3LFN#k#g)ns{t5+U&%Ms}^M73|+A zbWC=7VIOTijqqmt0>=9~FF@Ie5_RS<=8*6W`wp5_0kSict0+sfRDLtNy$cv};X8D6 zi8u-2BrJ(O(rI=>%dq+>sL4Ou_9jF3rBWAdMgne-xyMf(JuN<0Uen)`$M(<9es0W={!<7Cdyoqp$s1~=0VWo7)M2Q_`Crm z`oa}e<}MB-F0%@=Pim~>2T3HQQ{A!KB%cbH{Rwzii0h}n&xs~)G+h&<*(YX6^pV=s z=iXu02VzEU0VUl$ZK+5C>&y56V|tytXc6IdgI|zZm{UBTgU`AKia^r1B=hbN*uCZr%c0{KFd=ZsujjZ?ux22_|-_1O^t2p9#E6B~q%zEOKL{Mp4_~2@Bhs2G?54*u@?wnOT4m3FhA`7miQhSWp_ECr)&nUh}!LD^_-DaYi;4 z7EIO+2I&@VZMks~2k)A9dz3Nt13U1+_DqiN>UIGoMR685eoV{4@BJDUod46Rv~* z;2Yc>fggVa2`16!1Q-I6)rc(qUG(9A9h(~7wDsG~AKJ?4kg04b^vgkT8&TGl2H`ER zEg4PqmkO(Za!%2nxY(#BINrEm8*;tctaEwD!MzRVGRFq9V|8K8te!-YwAt+PDY*jF zj8Qw*)1!e6=cZ7LaKq`$J$yS#!_f@v8~B#@gKXuK(V?!!ulw=>1ok`z|M+w068yZK zHKL3qH71F9Z64_^6qpk#KO5V4b~A#>Qs^W2nW&;I;%nWJFD0yrM^wSl^!HdF4Nidu z%e=#jWYSo4V!xT^i7r+@Vmz3)h>yr>E}@deBd~jL^O$GbF$8L`dx(<K}aSo)AW*O~MMc&DIKo;eE; zmpQTpQE-=efHT$a5)gC6^`LBp8|2FF|H0Thz}D7p>%-kOcWv9YZQHhOW7oEA+vcuq z+jhI#em(cR7w5g_|K%pD$x2q!q-%~j#~9D=0hq{G!M!=ersQ*+ZsJtxBS$-~h`^xU zBG3a~VJcsT885b&cEJYYLzv_T_6nUStVtHnd@F+}-P9+DrI zIsn5g30?!p%oU)QM;Q(a8mNb)$UF)rnpF>WfUrZY0}QuBjQ`gDiLy1N*tGtG(fRjK zK%SKy3=(8%xCo`BtHUnF+_Xi(|M7>@3?86PPjXja2&F5(X)+>OxXQXsxyrgbS5>KO z(mN3aDm&RNW@c_THOr9mP=c;A{SH1R0X~jjXg>|^Q!8{E;9}cs#1Gb+!r)c{JU&Lu ztzQSkpTUA`h&%2M7&u+mLFZTjP)i_tpYROxc4p%VZ(G&CgP^ly3E6* zY`KA{1$@?y_E&kh1M1RSK=%&~AI`EQ{%yoYf{<@n14#UK4c5~nRmP6A+_}li5eh|- zCj3$h|BmJfR%p`C8-?5tA5Jk+MG$U5(K;UryU)s~_S2iw=bL28eq*Fc$=6v}i@mPQ z$mh)Lfs@y6>owe+Yj%$<@sd9{tp|Bugm`CG2jPN(N*gNjtq!qM>f_XcPBt0W=H-_6 zNYw%7kmtK>FEx42u^3r@nlWBssyVNJa$rNqpyxBwsVMHg0zIJHGvNR&aPe6_&!6F2 zm}BNUTQm56;Azu|VG=1e8uSfo2v4+>RV{r1B7-IMPySp8{9O96RuAGXjL`p!`rSNy zz=cxhK5IEb1E8bc>S$e*F{Q6R;?@DY9Th(x7BA-aJ^cYZm=&rb{aT0qho@fMd+q5) z3_9!_fsi-#QH{Vv3t_(}{P8kgw=JL4wcsF^9~m0}2W;O~%+3eB+8dpLA-EkEBwjbz z&d1MMgzYDQ%&yR3)DvN~4-6|_+S&1)))139O22&E4JnT#oxl`JbJCAkosbmV{tevO zm|52qAJ2i{CsFiiUm@N)Zr-r1!RxH%VA~l@mPW?|2FfOTo1v6mAC28;LZ{J!LKrzu zM`8UDfM1SRC0f_~(|uAW$ZK5DfV|UlNV(P&a)cOC_GE=_6-?P%bpsTlHsgw3IDUx% zlg7v{TuS?SHIJ2<>S5A5jSiSPNsOp~x`78tFb6-!94&v2_bf=+x%Y91J)J5m?ut{#oW zReUZ~yW+En!(CwK%dB3vV;MP1daw|2W4g5^>PKe%+#qaGtTR&}$CW=};G@rdn8g29 z|8ZLr4uhW7^E1c;0C&wLfxm%{BD9h|&$EHOjOIExebr?Iozk2>tlRQ`%?i$#ak9|O z%bX>DK;z*`XghIR63)B<4V~ihpTd?7 ze1dD>7F547l6gmZy~(B#F`=$sf<0iaxNtVFZW}ZezI35;UV&6*MH$kTLS8_|X86LE zC8NH}wIN|LF<}j+YK!2W){|D@^5YfV<|oZsj@h1VA$MFzv!K z8LGBZ(&N`oXh3-6cB3>#S)2D7A_<=(ZPz|YcOaGLD^0I-vaP@(kC$&%oYn<0_$Bcb z2N{RKWvo(7MB+ME&e(?^HS`6cJwo%8wXxUJ$2YaNri5^_dKmIT7me(L@LKT&(Tz%H}F0D{FH@c0}ar2*hV4 zOnWnJf9fb<)7>=>BkrEzaFd= zxzn|){KI|-1ONc{-$QFswx<8Z%m0<|ZaXK3G}4nYLQz9MY$uh9m<1`U8f;5X5^Mwk zj|*W!@?MpgQ7vhnhZOY{?)wX4Xb|@g(4T_H<7OBHwT9U2Z?6RQoO=r2&(AlQ9XQzp zu^kh@6gx`)^->b~Kq?{aP)>o3Bs)C*xEa0Bm=aJ|^c9GKHO2vkjbrG#Gx5t*9c#~C z^m^@qy_%8%9@nih?*ti^j^^U@k#a+DPPWLllHs7dg(ht6S!`!Lhr@z`Xps&1_U3BG zk|8)|>#RJv%j_~-r6DD1?bEhs{Zr~VIgGnep~Ws}%AZO(e(FHM!vK zW>FnpNBi>3Bdx_#2<0gu57L7;pt3awsigs|8nPhvnQ6GTC8kz9l&jU4gS@vpG_M;* zJ|)`a^b6Aa17arkbQNj8&{rh$0eVT?WRyc7$cIni6M`hg2k$Pa5}ZY>no#17!C-|% z0-k;Pt}`qdj7wV1JZnV&U#}ZFRsEHdASdomu$g!83PUR}gz;PrjbDSKU9wCww;ep^ zj~8Wtsn?xE*yx^=9;!Ubpl%ubcc_yMtgHcKiK~L~9~uQTh7VKkCy{(9uBK|5zf>V~ z2*ox7$9-0?vSD`w*1xBi>}FAo1xYvR&XhUmISY_8-CYp8D}^sSh2FgI{^GPnJUb!<{nOTy(0iZ)#rCY;+H`JYU<>l;lSM#&7(Eg6l;l6^}2|z6z5d9q}d6CwG&_ z+l#Br#TYzS3g@+w=J-zIxH8^@>I=|0RKY%>R|O6$EB!EmHSOK`AW!mQ&HOt?DTi+R zBs_;eMZL2I;nioOoKpJc&XBqE0*(bE?P?I4dMzx{*L?O`65AL4^>#}S&vR19V%Qy5 zsr)V`sO#+ER(y8U>OOX7slJ(rib;ur7sgY%tOo)Vp|j6NG7OJDQc=(jo^(+)aX^u~k!yL=7&U^A=1Sb_7jZ|ng7f{+RXEp(CNnyzZbP2U=s8g) z+$u{efG`(0oE~>CmI=^H>SG#)GwEVS*U*y+5!Ky5)59kW)|0SPBvUNBQQkwe(&xWitYBBIS^b07@gud1z97M}3~EN1OCDCHGwWvvJhnKk;r)R z0T}dbRr$nAX>~OU3Hm|3-!kfjsQI51$Sw)lCcVzI=8L~#!4c&{NC%REU(nUC=9lt@Qe^8F=Mj2W*{uDvl zj@;9v_rlzUKc*GE-6ZQKCDm2A^+x8Ev$JY%tVSi39%-6v3b#zA0?}BihxW`b<&54X zV{>-*v2yURa5mSs@Od1wvaxX1x98z>ROk143-(c*Mslu*RnPrVL07(WBQ)xuwds)Z zXfPyaXJq5^6jl~C^j1a)qB)HkMLbellgJ`Gz-pMx5R)MsNJ0>ko_wmKFq4g?r2>~u zc39@(wAL7zHg=S*PkUx5EcgfN#dwp&7~3j%116#Ly+qOlf4^gFqyEuhwU*Jby@P(Z zl%>pkezxwwXL;|^tk3TGzAoL$_?+C=q;YvtU}#C$)#--1>t|<}-L92)4KfJzWTR6l zUVAa;a3qb8$UW0}1hz}rAf1(O(HO24$eeORr5?-c(M4Avo2HRY)yfcMdjo$M*4vyQ zb!Q`&m)pD@R+pYsI>>-M^24h{be&F}v@2)A`aA36faQ9%lIePrJqV;BSKY|j!cx2Z z&zCT^Y$%c?78Xg?s50v1TCA9(*u%PlSQui-sep<1%tx@_)B}@LlcuoX>L*(D5sw7j zHPZXW#oGLlA|q+|F(03St7b~RVhCe_P(|TgHor+Iy>(%tenY?%xG4>Q*~<@6Vvu|v za4+992A9xP;76G29CRf!{{eSp;sVQ3ZATw+8=^Xb(Hw{oJ|=x3M;|qNNvjmOb%g1G zJ56aV*!ja*V^?=eiQKb97pT5R^4WP@!H^;uS9-?s4^;TRZE9htX$m+(ZeJ% z_*4;@+P{6{3gdd49$YTurMltF!paB3ykU43I5ixhs?Ufyn$aBYYv!hnKo_pPlx_5B z5KxpvmnAghu|=^-kUFR-FP0OfXR>UAcHRjO+cP;nIxyOIWWlwyusGa>aW2tZd1i9R zUK3BaH#SCz=A-G#K}LQmXJd}v8fcnN4}%yH;R1vb zHGEEmee)pe6{_Cc3{C9^Xg1?hW+S=+V>tFlF*O^Ohm0cZ#76N;>Roy)v!zTl-;;1~ zk%DgpglRdXpZ?TiV|TXa1XzzSvv}(qUm!Fb+u#Bip_{%aJ7w$YU7idRwgP}$AD6?3 zSM%1IX6?mz$2uf>T18;t?w@sKB2Voq!HiX8pAkpXPx0XjxWVD(7rsio&<(Ri_}}*S z?k^y1rlN@z=?ZENjKTK<@)ijMxr2XX7bSGN=!p~g6XTK4p|AX*gy%_)RU$-XgoDq{D&edOtM`1#ah zPHtb$2z5kNVRQFN3`U#t(ar;IH`RzNkWE5F7GHWsaHYQ%bqyKUiMw$D|6Ods{>lYhrVQ6hvI3jaqrn%5w zAnsG&H52g-7NYCcK=PgSLLH178pM`8t?Qf2Osue+_7E@!rxk8S zAzSVawk`yM{4I<(4zO}JJJObjL5V-mjEi5vrmxV7pVi(QQTAA(V1`#l_3x*zRNheC z&-9<*9`qqGH$q^qX(NDjnMIwU#I)&g9B=Sco+s-E#IUhElGfxc)lPq`kbzwJ85HLmGYR(_vcH0So3HYqa38r!7u5QcYkt3;!oAd&QM-8j9uaKA z7w_vW;^DwrLqCJ!Rvj9Ei6KQtN0UsoH;XJxSlMsf`Yj>5X$hOHk7Z@g=C531z@$TP zORK)?D!%hYoQ)_#GJk7?99V;w-X77M<-~PZ#Zh#!f9k166YNSv&EGXBsz$0aYjpL^ z+(IKJl!+G{Qb5S_*)!^gO?o#h^X=35ml0Z&il(BbGSVlDI2%6JSQnF+ zW?@s1rUI=PaU%s15i%e#c#+N-ekMssu;bpS_z&C1Hw|4Z)3ZR^pHpm83n_HJBfXzR z%eG|*4wlA@>Yvsuy*)3RdYYDHKHuJBcz<+;+IpW16$X&wp3$8SI7?Bc-u4kj*}mrL zsmKs0bmZ+=gE&GSd7JeYqRO+=h}Dq|N#iO}iMv(8kGqw?Q>rEHC2t%QqgwK840kAW zk`BEiyzvuW?FfRT2RQpTuV`4gdwfpq&Gi!uJxCp(L^)=xc~d9OO$d=4tpulmLorFK zn+(rNnF>o9JNv&u3@~L{0#^6-hWmMrt>rekPtiS^xmaqqq%=Jy(gdp8Q#a+W24|v1 z*^rtW0S6ybal%Witcgg#TCZzxRITT&*bL9MpjbyBj?6GNq>HyqBCR2|E1n{=;gS_v zs^y^*7KMO8&Q}^13fya?pLYh28lJ2r`}II$($A}x><~!N)lCul8tHqGR+nH8Fq}GW z&by+EH6X51Z#s>!Yp886?EjQ^9v1eGj{hKxwy}&RPT)=A8B@2B7Ia?&j1nHCX-Jk* z!5K)QVShYDc&5kHKPB7uWc|QBE;#%_`YrdiZX5Q4p(oV0kXbT`JT-On-b?LHO={Zr z@DI%{QQ{&?DQ^u$1=fgpPFrLUzbeA3HUQGvmXCn&uP#y25b3NS@GpcE9JZ;EcksX3 zA55t)Hnch=o~j;Gls1W42)2RJN^Q0tzuJ^JGqD|;V>vnJuGYNPK5|eVBDoTeQ>X(` zBrz%z+b0BR4u{49QAd8xt5_NSNh@*`nwuM-jf}gGh@7*>h@7+UA5MEy6i}n&6=e$y zD!ZisNS&0T#z$QgWo?60L%IHktVIHHuuKCMl(Deejkv+%ZL74`U4qL{r{dw|jLBWqd_=(ISPa+|r4rV*cEnvn&Z41dC{lx_5rd0XXAh}QQU&gmD+)aH+@`xny&p}cjE28nLTL3@)+j! zfo;l}VLy02&^A5g?qx?+dH!Ta^MFQuJrRu!1G8u6eWMSyXPP5~#TDi}RClxgIeAc* z1pPLui>rQqY#Q1K%pNU|NlLAc&=3y4(#V5X0E_+z_No60QnRBPc_gl7(8%M2fP6rs z{{ZKjwkGI=xGL&l-5H*8!$7`h7f303O5D^KZU3-ms?}#n^$T~~ahXn%PM%7p&oybS z$?J!1$&-kV=l$PI6eeJFMB=`Iir4Rb;Qt}X{7dB~Xlr9)ZtCoy|KF=%RD!iEB0t>7 z*ZT2NAWwi_em=n^erE0tBLu86y)rbin3rI+T{7We^oBO`t)e*r{p~N@URdMIF3sG^ z^+8s~2FClGk4vrh_vvX}fTJ6-5Xsb0J(dWpNa!nj-jPWz*5@|&-bn$B2y-r@nI~)B zn+p}zTI~@1T6;4e2AC1Z$g0W566jxBZ{eq!&_$&sh8)%f;>;z~&s~gxK*4!iO832) zx@uM~F=%tT7yD)iG5K2yjO%rQ#KCS&&6BZe&d+7pwky$(&7KSOozEr}h+CIeX<63u z4X^4%h<*N-j0+gm%PeczZQFH`)7kD`R_?O1Lt-qEpx0 zLP=(=rJ;iJmmZ!=P#M=gN=-ZJpBOO6(6c(aHZ(QNXC0c8Z%0=ZQLN4|fxj7{Gkx$s zDQ}sPVwdIiiYKCif4~TDu|4MKCRKCj?unewtU=NJ_zVG12)zwM8hW|RqXpMR>L&7H ze*n_U%(ZMZhB>f8B0dX= z*hXjt)qs<4JOjF3CVknPZw%0gV`1Y1>REss_liH3y}dbw<3SuYUGcQ?pQmh~NA+^Y+;VUat~1>!z=hJ}812t|fL%&6Fw4k_vaLl%5P zaF}0KrvAe`GL@YpmT|#qECE!XTQ;nsjIkQ`z{$2-uKwZ@2%kzWw}ffj5=~v0Q(2V? zAO79<4!;m$do&EO4zVRU4p)ITMVaP!{G0(g;zAMXgTk{gJ=r826SDLO>2>v>ATV;q zS`5P4Re?-@C7y1y<2Hw%LDpk z6&-~5NU<3R7l-(;5UVYfO|%IN!F@3D;*`RvRZ)7G9*m5gAmlD5WOu}MUH`S>dfWJ! z{0&B@N*{cuMxXoxgB}fx{3zJ^< z9z}XHhNqMGvg?N2zH&FBf5?M)DPN#Sg;5Og|0wru-#o*8=I!LXqyz~9i6{|yJw)0_ zi{j3jT#nPCG)D52S+165KRchAq|514-eM$YPimg2%X+16RCArIZtlDbDJO9=_XyMD zoC^b@fUv711vit4&lIo~XncD2uCrfuKH8E``e;Wk&{8k);EWqCUZY4dFLKdmDl2_o zMP+GW-dzpwsUA(^%gsgRdYf#-3OCJUsgmJ`fGQap4~PuIKu)ZT(CxOSpRyUl=$|t1 z@@9CcP9_@rSKUF|;BN%KHC+N7d4VZ(4JNDI)}~sZv2!hs#<)>M(?2^H1`Nah~_taU^n*CbZH+v)kdrHiM?!|KO#%*anDcA zed#~O%=w^jdIN>J!b>@<2;X8ubcCH!LUaV3T0*)*P6lv1xM#U>JO~Lka?P=Kai~qs z)|hDVH@#0tM}OqE%ga*c8vmF(0X!4gj}tZqMuEekF6fS&$@If4oJH9PLW&Ca2CqS! zfkAWlfh!<(6MyR-lrwS$!W1cT&?~9N)lQb(4OtXPysW0aAuCFVGK)qU3A{G5JDcRR z0l*vGOmm7i3SwqTqa#ANOHJHqtXj*J-5DUpWe*|^!LSE7MH;VKN8ppjX3R8gSfnPR za?2F6Xxunau(+jZc-<7%)%3K*{j}AElzPIow3=~#ISC_ByScS)c5RK|nL(TH%;(lK z^u*J*<(dfJ;}Uiev!~7#lDhATnmpSY)w#;Y`=iAW#6`}@HGaXSeT;jsEvDL&Rwu?g zwa+JW;0MPS06x|r$VLq6$(ka8!;gGb1K<%MqGP+vDZWZJpLjKUgN0dK?p3C{D&tcv z?8!@{Tp?UxYWG0JfVo|U^rKmRPEB&^qgnQp(hU_Mp`Hw%ZX8fw*h*4tt04)@@mcJ_ zE;fJG*eg~9`F2+PL4%?p8fN*l|`>hNJhPR@f<$JH}SDGe|xPodBc@ z>*Gnzv5JtD8GN(Z%CmDFt?t%9F3^cpug_(Pj_XoBpS6RydL6+wWw4E%2-C%D)4a@G z7Mm4d{CY9S+M^0d1mLZT+oHVm5%c>in{0}!k>iT1C7#O+0_1Gclk$8$rnAyl`57^B zo9|71ttYuJ?CCDp$oK~e9lPh*aS!gBLQ1$o0w|uluKHCle;NYURgv7Cg;E*M8+;83~Kx>BJqZ=o*mJS9Hxp=bp~uQ+Q%iUB!>h> zOs3rb^x>b}>%7ncd=$S7FEv%w)~kN!oh)w>XYRbU2#{7MtEP=KR`!!n z@c6cm$`qZ86iAb-P2zW?ffg_?Xz?EWLv+Pnv)j_^g>gIsDw>%z=48xXs ztXy*AgZ}XryXSSAq;ZyAo)P&1<{h#o+VX1pS&x;c*LB2ys@g^|Ne^e&u(F($VQFzr2N;Uxpn0XHISA zuG$StIAZ#%^;gdx$;F0uJ&fE3FfcOV5yV(?_06FH)#7uOG>hC+zoVY1>30J3Ep>V)`nJL7 zk-AP2lh7;4f1R`YHyo;x@iS6P1L=R_8g$rKjBniGG z7Wy?lA+#98cwsLqlOX_;2mj}QgJ00aae3PBZO))?g054Gt?|`89P}ud8M2P~c zY2m?A{f&}{PvB%59$#`Yk6F9}LtTVLr4`_vUk1t5EDB5ygR+ri}TnuVxHj)IP*)IkApp`A~+v|BqN+W)Eh{|~%!crx)V;Kr^+pMkH z-VRyWpnOF)zmUX=sW=EW7Sdz15#ID+-r^V11Ir+;p$0yW;Ox4TAr-xrzn_b`k?bky zeItAr-#I&+|GRSkvlRau-}`?TWtEDiE56bAOSC zXcKZ(B?@}6N2NN5qNO?(71~?1N_iSEI}#5>GtgSGfksdS;%*IxVesnmc|!B7!#As( zgkcT^N*WT)relVUBm%nwL7Ks$StYuLd{O9NFq1)*nGAwTTHGTa$A)1vhix>~^ zwI|7g-%^M18t{Wp1E^%KnR)wZ~8RVWvNJrwz|vlMs7BF=)# z!#!W^ejQa>_i{U|rv{Nps!~_x?0z#}RB!+F_*)hdG!fagq+6O|;|V>DK|}OwLHM{7 zc|Q4JDqZH(nqF#j77OTDd%tU=1^eF_*XUDD zLzIL8?i~Il6q-m+m~@v*S2Gf6MH<43mrr3PsXp3Gc@CI9CsQ(oIsNyL`y-30TZ)y2 zYC@-4t+WFJjTIFKG{Ik_q1EU8u@@uFmb&W$L!V4#wKElaN{V~n%%E8S=L#i)yK!!&}msL1A@L^Cvs!?xT_*E3Wy+?&!bM>&BX0zj}N zWsjWwc*VWfRRw=egZ{i2*C%@Q6@@{UL*b;Ww9X^`b!$qP0Sy zC~!r#ku$&SkWCvn zA%wXT{U&rse)rLT(?kEqV~XFw)Y(gt1=pD3_FfE4BEggPx@1S6tDZ0ZScD8*)IFipTitfM{x-f+_9Ia~$WY){ z?tP3Z{DseC&$!T-VRNexl=}yi$sykaFt&Eqqf_>L$NZHPzs|)+crni^~2>p+%^0$d5N?uxWfDg`lerb52rkr$|fC*BhMw(nq9tjW< zVyoq}-AbIbelzit1@;rbH?dVZ4>&;pH95<@;rcru?D+W{vzL1c+X*`pA(KcEsv0J5 z8>+;r?@uE6ZVy`ZD%&AHgeSJFy8&PgBs@pVc#tnfT3K5lV*sXjUg{__>Bb@itc03T zqY?ocs6Ce36GFD9e(^6_ri{W3S%uRcdhX){d6o=%W{9G-wuW=;LYD68tlaYm5QL(>p!s%^L(DaS;O>oUeRK;kuUa~kLY$|&( zd(+mnhx-oK_v;PQFXh%6i<6GnkRzH!%2|(d>!cUjnvoBDg#=J!3L2v*2pgtSQ*Gu z=RCC%>XTs;O!aDy!=X%QiK8w96-@&t*Yed=2*U&LS z0^$6&T~hZC?1Fp>6%{d~fV|qvj(ms2(Ua!9Dg4-@-?flR%5sI9p(hOK^Qdv5}Xb=$>(jo4>I*u7NUC zyw$-D1RDY8JH4QF@IEYTf;JSon$LXTqQLj_Eo^HoZr>5s!0W2;3#ol30_UhcLoGP$ zkgJGZqf;mXnmRac=Q{0!EA1#l)h_iV6jGE9xOGkji}=nk5xH7<(w?_Ql{_mq#X^Ps zDrl19$7P*mtYZXO;`>IfGU<6IfHEoJLRWA?c7mlA2snEJa+2G{F|z9-5Lc$X_M_6I zS7rTj8iq>V>2qDS!$9X$3AkeoqYUrRvZZlu5AXhe&-qj7DINRpJ=$nbm&yJUL zcJ@H|>CqgW{xwFY`cv)wN}Xp%GW9wd!vU)01INOK@s$_sz16F3W2^K@64nUUezH@@ zQJiU(N4T!2=C0~dhUNu;Y&_yVmEn~^nk$dh5N)a%9~XmIbR7Nc8u%miPwioLEmHR* zySN?!T9C0CcZeao2$y3m!0*@y+9t(59hZ=ALbQ%d^GQ)E#qI^ctA?{nKcx$+W2A#j zcLQb5NUIbd)gvB~QWr^1ng{>h?Ow+v4w|%dqIcC-N&%ap_Fz6b`6n}Ti zlkcCu9o78psV=AQ@NEwJpC&!OBKiLjt|$Cu)}#UDa@ZbfDL5^M1T5T#IOtMJZ4M~@ zXh*~47lNRu)o#ag&x>oab^hT7_!}++Tu>Kp?ES&$NgZ=ft z@|%3a9wO!rj!ufs27i70Pfq5L%DKY49NedjCV1fw36Mcf1LIukMiBT~H*#ef1u`|^ zS>3!r3^IrW&|73LfNdaCC%H8HKgW?VdxC6N;*dy^8U1woISrmJ&t9gk4IS(~pI+}j z@q&fnCqtR$5RhjBLdEL&X@l(~du#pHwHPS`dQ<&40f&X%>}7*O-vM#J#po6?Y!?LZ z#%8kSqO^!ie^^+#kQpbo(yAwf6w+F9{5 zxr2E+g=yfXY^^*w^#T)dy*>{ssx02%=D=Iv@JdTqIii;(pCh3`y+{r`Qlv~G#KJ6+ zr-QLYiWxU8f%SEPjUe~u6gi2Y>}jl6O(nUyc^qx33sm-56?`f42*06OBLegREfmbNUvvR#>{W&4DL|NPV+As&($WF)rTOnFv3La3jr4-Hn6zUC4{4}gS4p|j| zXte{N$&J}b9RjH;Wk(fQ8MEm5MeheCL`nuU`LK6JG^(7x%thc4+P}<4YJm2`*J22c zv@7LA`$kj)8W9K8B&?Wg?{7p1U09yEf`82HVE-#!;om=j{^PFv=Zxw2&%3cI$y#>) zTgCC!f_Z)dib)na4Hdu#m6(?wN-ysPJ}QLh6xK=aYKgsA&Fm_COZcMgg&!u7ANCJQ z1XoK%L48~Ry|l+P`}4*&`|+0JdQMOG2Y}pgI4JTwMt$ljskkbA1%8w}3<-)-qB0f3 z!I@9PD0ju48_R&(5GqUqe(T|y$)@uJsaB(vrSrDwFMP-G+sqx7fdi-dcc~=&t}{(w zTCssQmj;uFlFp-e(*|_9ORZHD~t<;{*$w zNUR8S5`2=qbMkY8gr1sJ%pa)y>%Zw3wB3ic9p(>p1~$Nh_L)^oSkM);n2a2>6QF^* zQ3Xp|`{@>v*X7L_axqvuV?75YX!0YdpSNS~reC+(uRqF2o>f6zJr|R)XmP}cltJk# zzZLEYqldM~iCG}86pT_>#t?zcyS5SSAH8u^^lOKVv=I}8A)Q{@;{~|s;l#m*LT`-M zO~*a=9+_J!`icz0&d98HYQxgOZHA9{0~hwqIr_IRoBXV7?yBg;?J^Iw_Y}mh^j;^6 z=U;jHdsQzrr{AWZm=o0JpE7uENgeA?__+QQ5)VTY0?l8w7v%A8xxaY`#{tY?#TCsa zPOV_WZM^s`Qj|afA8>@iRhDK(&Sp}70j`RyUyQ$kuX_#J_V>n2b8p4{#gt6qsS?m=-0u0 zD_Y*Q2(x9pg_p3%c8P^UFocmhWpeovzNNK;JPHra?NwY%WX^09ckLz+dUvRC>Zu(= zE0Rq{;x~uY#ED&tU6>T)#7Tw%8ai&-9Amoh5O$^)1VfT3Kefm=*Pq?2=Wn~J;4I3~ z*>@-M`i4Ha{(pDXzdDhCv5Bq2ceu#EZAI3Kh^k0FHuZM)4Q666NzE%_fqXjP{1tp~ zQ1Gz`Vb+N(D=pG$^NU8yt5)T{dAxaF{ZoyB$z@NPrf)@G1-$w5j;@B_B(;6^#kyDH zZPVPxZPVGFPoIz1wzL3+_PWFB6IuBtIwEL}Sm@{oD8^Jf8UT{5Q@3HMRF0M4D=_E` zD(p+3wNv(r!=OA#^r6zxnUQeKY+Tj~-6J`c$SGNlHTst`!>PT8oP64JwLJ zo0&FdEy@+u>gWQrXTdhK^p&z61G=JYN1H5KCKeg|W9c0j1L*oI77G&T&Z5-HqX=VZ z#!c;28ttj9QSrIsa5}SB8OhDXn$8_FWX#?SWSGHu>Z|1%HI~2`_eAKIXQ46}WVn1C zq4Vx2!Tj@NE9J(=xU22vc3x9-2hp2qjb;foS)&_3k6_Ho%25*KdYbL>qfQ#don@{s zBtLx?%fU}M{>-*8VsnKZ{M-OZKZ2E3>;ko6$FWGD*p9T!CSb=4~c)rOoo5E`K0Ic^_ULF141!8WqUJpg$IH=MuWY`+G@#?Hu#}$j zDKKwbn1(V+u}fexB}_7WjyMn97x-r)1;@-dW1ka*LV~~`ZMXb5jwOa|#_kzpH|1;~ ziM0Z(3(i51hF699k}j_R#YEPp?^MUV~lprsYT9X z&C;nR9aPs;069~kp*WuEUfXSpQ>RR&>8I-|<=)3VsPW4F^3DhBOV6Nm<{%}(LoVbz zXCz2qe&_se*qqX*hi8u%6IS!95}mLi-(R#SvKM_{jFaAOIcxIBVb0D z#mxPNiCzQf@=e5;1EQ@f4{xlXGooG1uw`hnwcHQZLq7i3=x>PAecmrXKu~j`52SO| zuM4u^mx46I<`|*yI_~W;eFi6u51dm-AEW(@z|V9K4!C*wD{)wHI{4e}Yx$lynI|S; zXE2fV%8_->;1VDQXej!4Ogi*7WK5aj-uw@PdJ{y%P__4KNhoh}7HN zTe+&l792&XU2;`=>;_P>=;%@BAP49r&lpXeMrS1>Y4#0|J+jcu^7t0z?)9^Ups(Gfh^lT~da7_I!7SQqo`ayuRhc*HoBNP@sr{-|^8? zZO2pGuK$RS-u}UK!vzE+%OG}2?9bhm2&3fGYLRQRQ|9j-Y$VA}!DbMeL`e#L+sv5= zjj4V3+jU-C*JC8#R*`7i8LXcNK6~z+3=NitB4?Lh^QC_OW$sovcgmRdCXvymBY|-@ ztoIRZB6?q}#u{onCGn>H+{4iFA}o)(%D;-LUnYogL75kPIz`7E<~wT?Er_#ySf|aC zV(OPMl&RHZ+~lEHks$k(dahPU-n%*=RWxi_LmoyHn%Xhs`}=1Z7VzX@sL658PZ~r~ z)3-wXUIRX{mgZLx#p(P9TE1W>*(hvysV0P~9&Kj~vh_DYUCXw2!u+v^jWX6)+e922 z{j!a28HTt%W<)TvR5oDpvGZ2HbW+w{5yIjn=VP345an~xUsRw6M+E0>Yj z%L(l~15e>#g<$DAx#;2NC*lZ!Jgj5+uyjAGo%6HAIU}fGaKp}2Z)gwfjLfCa@MQNm zUXQT+U=H$fAjHv#W5BUVGinxT;W*b`BL}CX-fvd}$ZO!aei6wM4lvTSq1US%r@>b| zHOqrj9@-~x$+*(lL$$zA$oA?3M4-C&!c#q~H_=hl2;2n*%pNDN!M=<)zCx^9IzRus{1_>%iAM{3Q?s zIu~?m^B-?+TrwsWeuO-)?BonmXlc;AmRzV&e%-Hz{5S3_UfzCZXlx032W zT&r`5@e2?Q5v0)Z)gs03?%Z{(bg*=^ie<&oU=0QO;nA0ON})kq=^uX4b*uT)?v6`2 zwMgyt^sjpoc_|NjcyUL18e0u`Gj#jg-i@{xeM{f;`>%s*lDfN-MdsW+>!Zi)m`c6hL;eALmV6u+0aZrzWGeL zICYR@_=fPc)$s3}jn}?$32DP;h@$A-Dh)QEg%wTMGpnZ9g|~Vmf}-KiC~PcId9XNZ zNfy2&CwYf7*;g?iVuUU64A`Gq4f)XA$s!mbc;a*a8f(A3e`wySVO-;*M7dXh*>sRtw$iRxXe?7VPx z)^wzvs)QWJUcB_?N2d^{Z9KKssXr9v`3(mV1I4$q{RMlfp4q-Bxf@St-Pw3Q*Ef!$ z!{NR<=B)=|K&A(zG8TQxik5kFerKk^W(N6`tJ(+C8ka{3yfhI~zuw$buwnXgvJB~x zC)%fCrD})mLbehXLw+LA62K1)!9-)D$dTZJ8+OY7(gHj(3BjTIp;EQ9$l+|UF^9d_ zsI|CwwV*tyG>^V5@L|uh|BTI1`Tte+)lqpQ>DL6;;O+!>cXuZQ*Wm8%Zo%E%-GT=Q z?(V@gK=9!Hz1i9QWroSl=Bso1(0|bP)>~a&UHw!&_x2CeuB}V3o=||vZDIOmtQ3|; zk*wrlvN{Ud&*WQ1VB7LkuIhdpL^7vi;l=0K!xQj@qNGoNv7h!K@d`!pz>*WGS zUQ6jZ%R^w&JQ!>KEM_Fud|U(Go2;H$BO*7DDsdNuP7Ue@%Lk>dHP9Kogwl1SRm7$% zkSjCaNRoy~oWfZ!o6+HK0>CoErUVy-=yaaGEt_qOCd@O7rZhzs7}Lem)^w+$xQ805 zju#fFE^ejJZPwJ>IcaZ>i;K#Vw3C)GgC^9u+kLnyg0wRrc|=z}1hB-oM(x!k!Wy%o z-x?x!e=h3iBw>H^e5PFrLRF_K?VO%^HO6Z8g-2>G0TT$?#creEyEZNs%%JIh(M1Dr zB;8ZpP6SvOPlsZAq%HdXaw{`9W27D{MtEJ!UC=|0lRjzjK5qi*ay4Q&!iC8Wy>SFu zj0d%0Z}HdDWg+miRbxv}A+L9~1Dj{J8-<}3&AcW829ME3Y1&#}8IASgK3pqDUSE;G zlK5hDo2|$(E)%Am^!qm^N`E6Q@Urjhw23il(SP-ri^?H~?^NONQ4L_lZKoOQ423r} zfXTL~Ovzzj(_1-q_UtpZs*&PPfTn@}v5%>ysx4h?s)P+P!7J8jN^aFo*d?EUyh|bQ zx}dY`e#&CQ)ATs|_QcIks`^uHY%prn#{gq=&RgOmJYfo5pF)!@6vfFR?y ztbyN6rcv@u&QZE1zfGVh3ztDrWt|bP3LhjyoAhwMQsWM#Ji}lOjcbxj7p!o>iP(g? zK$IaHQsuqU!(SJ$aQ*;Mvr~ZA(-6!ZQbG6T;A%?&6PqNeosTmjG`QOI^^lE$;ht+b z7HvdkAhXSDm67c4y?v(TviM@(qo8Q5(|c2qU}LiDi~*#f)a15U%_O8;u$1D8jXXc9lF@%iuvg_98C$X8 zRJo*VZ`Ub3f7@%H$=QpJQjE+^0xrqPU65^ZBbhleKw;eKLJ`K7zVVsFGT+4qM?x0O z@Nht4#!zj~y`m+1UitJ1hxJaK?ef+FKX=j*3;)VzJWw{@+RKm=SOqn*gL(zoJ0(UT{WdEIbH*+qvC00ZXDZY`QU!g!N z%~QK0nxz^vYd&h-^|?$)<<`voGx6I@_%25j@DLc)H`;~eZQ?cFsEuLs^n}{|wrAj^ zy=gA0t$}fymYPUOrchB!R4V!#b_XFWNL|D>($kiG;=Cyv4Yqd2_)m6)g7PhGpd!WBg{6Q zW~;u{h29hhq?quBR>qOkz)Jg{CI}e` zT5{7mfPm0AYfHs}K{i1^rbdu*w`MA9P;x$)bK`MQ6pdt?WoqB3kN^~i_BF_X-eQ6eQL8jDbj z3Nv8$vViw4I>Jc_GxXD6EW~BmEKMH4C4J)bzv72n(PnDi+I!ut`K7k3w{(=MP`yKr2H^(skQ@E}M?2&|}yx$wN;7ZjGGeyMYC`pvItQ#GtEatt%w!a5Nxcmjn*KNa4~`M+o!7#-O?m9rje^v{vhdVCwgf-eRi)r{UG}$ zp;ER}Erldqqgo!i@Ne~cRfRA~ge#+%rguKQges=0vi`(igdBvNm_$dsri5;!-w%Ou zJT}O>?(>5Na18KB$DJ{BPI7AD*(Hqg+BsxnK;>dpMdwY!!6piTO1EJgh1*$Npts+7 zTWpfUMfx$ZAK02m0gnlV%3%_uJp0<Gr+VYAu{0+Ep< z4p*;LgH%5@7-+L8Ei6|LYi|`efW>KxsEsp;v4CI-o3N9ZAl@QV>4JVoSMCy-V!9Bf zyn_Gh9J!&R+CCZZ1e5}vfZv)U|GVou>)ILqZH`=_bR>%`kHFKY)pF!igPP;D4xxwG zf&$GlPy~&{Kn#~U!`$iJc%+Wr`04BMT$I=u)Wa6MjBo@ouMZ$mOe0Z!Dph1NYiw*J z#lFz_>+#dW%)_I%ix-_%=ZBA5M7KE%A+%tRvr5ydGh-%JFK$i zB3OA^tlEuC;)otcC(Ydu0@v~{_m6vBT)eA=%1#=&MpkOyT^M=x)Jn471lC16Jgv=(LlX%yQ9n^&IEf6BUR4@%S5)t&5e(hym}=0 zda=G&VJw>Pna;Rm6AuJ~v|ELXYfXElX$Ke1iP~Zw6Wq1!X+46@C2)!6oNicgzu=pE zQOddc=tb*c7mn8Q2V_l==6t%R;RK%jFBaFu8JXtXI7Q);*zby*jX}HZdVL+#X?a9) z-T!k2dvy+di-gKl_?iE9Vk1nTQmH14Y;NPj24m&h%niyu;7lIaI(d;Trd(kb{zOlq zLtI9Px6TD*Of#+zJntaH55X(1YVt}Xz#Br?HNH*JI5~v*T7k|lv1~Q*&k^hpd%ho| zLgXCAsigQ$6(^L5096aN*(QRve`EdEE{|i5Rx=9d@=Jg&&-Oc?g@1JUmr;uZrGG5| zcv;O)%5!2^E1ZG}!(v+-`Vhb(rt6`h)29%g>0^#k@2gKa^<-_pZ-l+?5ZAjoj3UZh zVzsZ9+z@gH1U)&%o3C5zyeqvP!QXa7hBJRPxcIID|CNM#0HKClA8Hs$TT(S9X7e6J zTS9f~)DcPq3L8nA$-xpMal?|4*zVR7yv6|k8>}a4_mp#51jx#5Ic{=3X7K{c=<+;{ z|A|n+o+pcD(8y|y@q+T86^?o2*DtUA-!)LLP^6?Dd<#%5U69qP;9ATnDPx&_3$-*+ zE`;|r?rT#ElWSbw0Kx17F4$f4r$B;J>b^JM4L9pNn>*+cPbU26rnIoZud#}8OvzHs z%#^h%+#+>n!+awM6q;GLRy$*~&qFh?yr4Ihx*SU<`e?wQ6kp#s)TmLRxXzNE02}O8 zVmV5kr*h{dJmc2yV;0_3!D64OEfSkGo3Ul2w(FlZ3^)a3?an|m?x~!DYalgXDxWMM z2_!D1QDIxIKPVurQj%}rI_``LGFbEmQJYq3HvlA8;Ktb}x%8uY2~fhnEXiD;47C^nKf{+nBjMFC0+_PZRT2fQ}T^O)I0*d4o^=L0|b_ z9B)cG1ro+40Qu;0gJ$tl%I`g748+z|j-(UXzB+^968lcpLQ8lw=2Se_3zL7-?rtT_ z?eDP|Iu{0t&Oknq0oobWf4|At89^E;x3#o z$OHE`rXx28)OZt|0qFIUM!ELTWF3K0k*Xj{#`xl z*UMx7C1#TFPV0wy6wgPsk4`c&b*Y=q;S{12Rw(a@iA?xW{GemFZ&)RQjs}dBjmSuz z^FHUx1@hj2+~tKjv%W%vF?GTl%lNdLIn3ky^ziryyN>YQ!=QS!LkO3e-0yQsHR<3ou|Xy7KP4mGJfd5^v!7>w zD++pZ1KCu^N}b;nB1b{1%h8)VicW2BNbM!K7vB5jb8pz2E^+P%<(kCAilPTNGx#CH zJqz8j%NR0h1TRuy-7B!a4v%7!Mu)M0;V~T$<7N8&;qi~q?jNzT1O>o60C3-@;Tz)X zwT6<&Q~i_{X$&bg$wKQ*ss%Io9lU=Vl-Ymr_CAdEm_&8=ysR~H|)lK)cfSrG(@j)$TOctVaY&jrY%Ho zFmIt!e$wa^@SJ$UF6i|A+wzyqcA72n6iDYIAAz;Ea9oDu9y={vRUF)qphxQFnQL{a zyw>bprCbe4=jt@atOj9h%kTm3*(1nar4&NGUl3T@$eMQzy9-B?dJHHOtlBbn82}2J zN1t-#%_>b5Ih^)mRx(AyghuaVfIV~50u{($B zriCS6$G3vGADdtw=P+dA`y{kwWmD$zhax7@unSDma@i}?&M|C1dV~aUI72#RXX`^J zW?ypzfKD?E6q66@q<_DC4U60aPA=D=I}{h8w>@nsY{^@Up~~?2O^g(t?mA4Nm*5hw zsAQ0Tym1{4;Uj9?Gi%V8g$LILGl6-HZm-bEOoR*lElO@CT7?~*DW1RycvKcJ8JCVw z=&0B_T&!4EPRdTRe$VTc^;EyKj5lOV6ZE*F{N3THz86+GK20%QmdpFPqMI!#rpC!K zWm60zlo~zxEwLCY$2^)MSZt<&F?TO=#aqi|7=P#>_yfB5|Hq{F*Q*y9isJxX1e7PE z7DHXjobP!$^?vF(Zw)92#3e)WKS0$WBEx=IEj%iORdX6VPQ0n=7)*n3KLh?i+V{~r z{%q8#LeSid-C;HDy503;$$Isof1GX&2<2>~1K}$ihS_9Iw*I6~5J`P9XQEQ7g?xW# zq*9PC&HjK+8ew7_ z=#=9Xh#Y4`t-A*iH)0c>klws4b(ICoS|enmnr&Oqms8=DhLKbnnJzq-qRP}Zv`lN) z=G6pAST~ww`RQhl9r1MNX*Ahxi#Jj$F}GTrTS2p-p`Pg3aoU844?^=Wko~KVtL2*J zbt*iyW&$N#xmah{!z%8=90`O4^B4$;2luzVu`L11&p?<#SBBk)0tz2$FX>80`4_+9 zlQgyjE)>4&YhSuBn}aE_Vp*BBlE9TD@HGIItEtrY-*9~&X}F>BDbkvw9d^59mIrUz z6QOh~50o_8NL*`owA!}YwB=nn4O+JgT|EZg)n}+wj3qm)PTiXz6D*^~Px{E0Wrs@dqn?RqXU-v^+fKU!7h{t4^fY@Mfy|owlE*#89C~B)yWaFEB z^{V9xQQgA*>|~`Sk;k7QC*#eS#uxjYOv1|gc0u=HT0}Yox9nL{kE|!54l#z2{^*^p z$H=@M8WRcrX{#UnGqqM^QFTr z>~c18jbF)0ft~y`F$=fcizTmRK1V#&XTJFrBDpXqX{WR5CAe=K~bm zYz67LIwwfVop|=~w8QT!@5t|X-6dCa2p*7gxGm+30X*aCMYQ5 zY=;y|g4bB#k4TR}5?XTvZ{KzBJ5wFVsf^xMDw>?wx^HO(#5UHxVhxiB{zB zFlv5E-pH(18Zt2Mu7`OhIU)-hg*?Z{Yd(>8yT=4Xt*Tz%11fq)SI84B{M|9aOl%72 zYzz_o)HXg-fjp|xUqHG*IWO3$eiw~ieSEcrO$Bc8WK)02=1{Yp$J(yhReWcj@VQS6jiKP*j!U(x9 zwaLJ!#HLhYUw%c(_IH%53zjVA%xt70o`|hRnak-a3xFpnGckkHUoa=zpCh zZ0pUEZ2-EJ6<~dh?{~VDl9l;Ctgf{w4Zr&_W8fJi)@9^}L^ul!AsGrN0-LR+x|Jsd8c~qMcH`^n@zQmA zyXW!f_Tx$83DCB!h5+mqG$;L}Kv_C{T-SDQXS|>3h_Ee7s5z|Nm#s{^UL2tZMCaj_ zPo%)G-$0h;Rt&?EhTT$h^?Ge1(l@^67VJVNrf4`xl31auNNZGWihf%^hb275f*njS zegGR+TV}O0&oo~I$L)m)Rt?(78{w6!iOeF10h?xR69MP(Ot0Y(aPKvq!|WQCjR`$K zqbN(5Dm>=>nwChby^YdTKc=N{=&!TjZWb#JB6qmka6aYLw38C~n3PTvZ-bPaxn{Vx z>Zz@57a=Lp$n%aZ<4bn6zCzGJ#kZx^*l2gg4AVxrP<{NVRnu&%rEmuAtv7Z-C*#P&5i$j?%ljf$JHP?}*~Lp3F6mbySnI z((Ui{A)@PQcmnDU@wygo@V0R|qoaw^{G^$l5E<`513g9A?)`YLP>c4Y%aC+{jDfsK zXbqkuH7RbXNJD5^A9O@+HV)cb?|xEl%~FQj|mTZ3QNW~@iB_A>p_LGOqy8~F~OI&`%aigq`Dy2 z^QEdK7D-9@n>ZaUgeG=A!G2gWYa%Wm&=SYHSqOYSh0ziv)b0fST?|o>41Mu?&M>9E zlkfnBESfOc@7*XL^wG>zAN0pInU!2Wa3kqi7}@faKfKtB6>2F zjsKWdXQ;urD9+YvQ=PNN0gQ%Xfc&|M;0N_%fdqX{8HE+&LFplbf?dRAV|@pulT(1? zi*sivFXhW}bv#u{DwIVeLgdRUPV_9xJXd%vPL3{DHJ041-Iv_VHTFMWrKF5Vzb3uf z+B)QMuWFlHJUBb4cV2zCX+{=i4wL&j_4>~H_CbUfe{i=7>yakuNf!TLJ4b=@NN1|# zgW48OhJ&dVC+6YYmu~HpIp!jDRnx?HCtFNA*Pyr3D4`OZTHSG;n$&NM2aQ9+r7zEzO$MhuJsSF$ z9H8mLwvi&F982}CY*XrXzC#U!Lf&7p=~v(Mf`lT4XI&M5KT zq)43OJumv62Vqt8stDHmbg=`Mf~W%)tLS4&#OB3*bKw&yk7e@D^JX3;vMP{Uj+z8* zmz$wJ7rmJu5A?#zX@0j70W9DEoNz1W``1gl;%EdzrOm(PjM3}MYTF&X+SY8lN8 zMTc<@3}bY7ML3u3J{rh6ylW7uI9A=9$5A(LtoBa&sA zSy(C!VOc2$O1b2rr6Ik=mmykB;7l+ha+EJh_{)~{#3Q{u*wr8`nHzK?C=IF^@?~EX z+kH^T;jtHM{bMLu>Ugnw=vA{AWCSTn6Eo4nQ#6FosE@T!U?H}ok~K*R4w9E0W6-2n zVd}A3I2+U_>jfd@sosnlnPgzX4W0C4bFJb9U@7qGS~nOAdq_xD1xOOn@wrD2PE$xF ze@(E!vFM$$kPr2iO69j1Fvq)r>U?bhlrikgrZMQ#gZDKlU%tYJw6=TW1528c#ZOKlYxWLIsDi#aAX9#W>#7OuFMoo%?_{MdLk4vR%ySNre$;K05} zF(_ql@Y`E;u>#@gz}hO|%7kqi!Pq0R7RyG=(9SJF$`~>N_N*2jc6TJ%B&gKDSpKR# zjFT0Uq57R1DR07pg5SFp>5LUHe1wy|C~_}s_=t>XWsHin7Ggkfu_s>F8%i2CfQMQS zWL+_YIvDf7T(1nSpIc)7X%=o_!8E9aU`9W1Oa8WP*(!`N#x)fyQ7NXf2{bz|Xn;Rm z2=^QNfPt--9R~9oruZPcOoVdZxmn#~qtsMOf&SBs#QL1+Xc~vbplOD!Cb#2>{jrTI#D-#GOHVCgl-ksU{tUszSLNL7q&3UM{@RJDd3s0>s}11^nD z^$nqNeQ-#1(xV|w$`tsF25+}OZ=f~e-jSf7b-05_ntV4@ zWE5sk?mG+&2lN%o34xaBY`O_c@D%}P#t6CZ+Ow!9hoRktiC=WXCfKbe;G2fCyIYa* z-QMzE10g`Ly5wM*_mkRga_y1BIGeUEty{HEWe4vw6mI53`U@P!^kKa>JjGk3g5`UY zRhCj3%zcG(pswZ_(RUBqo>(>Q^0_l>=K$^rXALNQIFiQSdK)CfKNQ-ZZ=4MvwnxF- z_6<#qZ40Bgc){g%b94uMtqTISJ>j#?spW%+zx6H`kO_&DegRZyZ-OEC+8{*W9s64A77(w8SpD(0sz^bIkUx`nwP$Rs z*UJz4`KK7cee}U@lKtTLnKY{(&dcv}=CU#HO!rbnqN2?hHtG4HRC=e}cLhw1k_gdJ zD-K3xFDzd~a@M`13o8Gp&{tU-#&EoSa;D4r6LQV->sxBW3PmBEo=CRG`!)L;;T<0t z7T0%g!2R!UT_IB{TQ7itDU>y-VPJU)P1*Y}BUrrT_dfd)kyMC+EHvD>^DMz(C;;Zgq)btTJ|F%u&7rIMWg$W^4avXkr>g!76+Y*h#fC((R8h8t@#u^J|{i?fyRQJG#f#{m9;mNC9}LE8A9^?DBEW zVkI>`w+R|=|CX=DIcP&XRhYn+s|HYt2WAT1sIs1NJRmH8JA1$ocRfn|Hl zbGLm_DM#Jp0YUAO0RN%Pf_&81bHJC1^tOf&bw(C+N0jf`T~L~qt@^OaS8Ok{{aYq+ zmH9-I;yF>*ZgGvSm7Ckdwg#6BC;+IAIIdZR>T!O2coHisQaDQZ zUyOR?FJX(TmQWQ2keJVd%55}SwE`(%qtT(*gu5glzETZsvnGalRkD_hj5&q!6m`gg zz$i^M+ho2;Ud)ZD9J>^V(MWy`_kEktmQ8*K$?pzd>ACOl zlPfScddrpjMzgZ)8>3OMvie!pnR6gYB|tC2(?=ecvQKoq4ArWE(ZYbPsu7*WVO=w8 zn~gFe?O_x$c}lO>Pri)A5gr+IuPb0K+(xPKTu$6A_;culTAhDt$bi&Vfr}`enAJ(o zg~;q@+-KVul}Gfs?BTiiOt2xlcZB~hUUp`6E!~9)X3Pzq&n^IJQWzr zVO5cdCKM6*_WQgSuxaVXMGzq3ZWJdN%@ZuCLo02}n;2(6 zTY}=G>Om*K)n$254w*>weMYee1Z|)82tyXc;HQ%qjLkFhitUDnqNWG%ur3utD^&Iy zDI=7uLX~KF1f%qxAn$6As@9*oFEE+|N)8Av#zC;1`F7YY6$BK%eBAz)Cs?S>nU^Fw zf2|;|pyuOlDlO!SAJIG8f8=~U$zCYr@y^Yw(0bwqOD=G2TF4l0lk6e03yO#N3}NSb zI-gXHvv~t@Eo@^GkMjT_0-|6IWRrr2xxVk<`f7q1;qXutK@oR4K~tcHl> zMvxU>=O1o%+660UI&)#Fixp`&r6yZ=px#wqy0=oa42qQ;(xdve;LHS5RAm95D)xq{ z0_S2?SuC9#)<$cQU0PJV(~Wl7DQL5jbpyeokYH$ofxmh(lB`%~~(jFVZ! z_{l*IM{x1PiIf$3>BK9{%%$~`F`6ONI3+&e^BSs$SkKYoNhY;#P>F7#JIg_U)vxWD zVKEa5hd~JyHU{s2LimCtg#97IbF4@Y?vJ^_Um=JyH7PSA-vO;fFh{aZD)zY0Xvv~a zqNz)%M1SyJGNp1z^(T12Q9be>HzX?8{-27QtUDjG5 z_6=V*Gk9f6}LAT1j`OT_C+`g?FaGO}P1!JKAQ+H+{ zEo%n2slwjD1@S(P&=_AJYV(9yS?Z;Ll~t~aWYzR^_H?#?+gxzQ(y1=*cIe^9K9Zz?eadMLs*&-- zZmY{~Z_U{hu3u6*qWF%|j4vpO=4v$W0y4Nqz?0(RmWd*rs#>gnJCZ@ATQ3D+S! zS0T(ZnY#u{#Cgh7kks!Qk9Bnbht@GLk2zrFB$iiT2X6bVL7^z^SCe+hxmjbu`?STj zD&*!fK;1}J>=bPQ0 zZ`bfL-CKn?V3V1a2%b7bY;^?jV`Joocc2qXnl8<46msCMaa^5~+5kEJfQ`f=1wt1R zU@3l5bf`ly=p?~UU&PmEAz_eBu|-pl1ydyxSKupT2`-+%UR~J-Ox{B#tq}(B3Ql-P zlc^Oo0)1H9@Ni4pop8R@yu+KHyl#$I-O#$AU6bV7R@v)+;Cu{_^OHhaeVwbvPN5?* z50p$|U{83@;0DvmBK|p}UC8zUBmiA(aX6)6@2p?HW|I500P zxp$_vuoDa5P0ze-VKpr4#eKxZai+ej@O#0Kx0+rlUc!8$NH@1?cTmhWlNRj|i>snm zhlgNyC6Y`MsT?MjJl=^@=es~k8gq2?M&~YXdbfD;3ux(vKiusmndCrd&B&>Aq!_ii zOWc}o(`bIIEsts_L?>nDkx!m+A;l|P1{!<#dijduP(6Paxb^`uvmU&o;N6t+g)b?Q zJ#jwTMAa+2=hxY;`26Qt2Z>=7w923fgh?Ljc%w^an?~U zHlX`HFZE^O0%JPIIS7=S{H^Q!P({j53EIc}NUv65U~%YXnSs~%CQa^`2p)w}<-C0@ zd2@&NtjUR%PrRw>E|!@I-R z4e5QB`s}QFI7B;@f&SbnZ#Q;I{EYuNsmlN_#CUjFG*eNmK8g^*=kIj!7De@#SI}yn zNl_VtOZLo|{GzUu5Ii)%YG+Ah;&vj=IQW za_!e|JfU6j(ByyB?AU^KR<6GgMa6#|B&wc_X@De7jJA8)F;uUfhpk{rT)kj zQl)A3L_>}s;t7|Muq{#MwfGf@u9Q_8h7Hz0f40&AU)NCfTXU1uhUz!A+Dqc~p61lG&s6NFJ^CkNfn99Ln zxW)IWfx0+B9pL=VYJM@9HU~Ca);w)h6hnZA&6a3R_Nmqpj7v9BaKyy7<1{fc*0Tbu08BQ3W#o`80kIHht7t|bEsU-Jk@MXTPSpsNjMzB+W zJ1?*Jkg?|`xT2tOxjI1iX}mV4RIS$V?;NXKf=oK|YzY6(<3#ZKihRZv^~ zoee!yIg4v<5^*1ujFn$QHfx z2V!BrjDzva25_O{@o-BxY&dgek_h(cdz%K#R#&nK{{^sVb;S=1C=(5GUi1TZqq&L0 zsq(7$9ufW)=Vc_k)>sXtVSCP?Jp_;z@TvK*t>k+P=nmxMBZ^xKTduOy8!kEY+LZD( zQuy$vDrRKf!eY^AxbRT^nt`W;m0$Lr?g-|CS<8Q%5E9?=h7%5T`!M^^8yvUBegdO# z#?EQhfL!Ab(2LhQ1mAXKkgW;S+XRn&G=EDhy*pnm)1{Q2A02zDVv*Gxq5Q25P7K_N zs4d8y7*_04Zl<=Vc%?&-s{s%x<6HoaN{V6{ml^0;l&UwskZ&oJ#TOU%!-!w zNE@$Z#ria#g5UV@1b-0{{GJ?f><00{0?9050>yUYukQ#`l0$m(59F!5nQRojJX@)%-W+G{BPTtg$?_I zuBg}vG1!E>yUMQ zWeVln`N`06$e3t#G5}f36b*wBEE7FqATQh zm$k)^2%<5DmzrzQ9gI@<@3eqX*95>s`UU8LR)m;aL65E04MpR%R#QwonHj2&t%so0 zrPC>kred>bh;E#mxTeMJ@}c^7QPgoId%lF-lpEi}jbFX>wsg9?jH@WaZ(*zs(hOOm zkZ;ty2<`!W+;!WtV&Lf}yro`ojcn{VPrs+TIX>DX_gtVT1a<$cEG^VNEEJhXBt!yX zf9Czy1>CrdR@7F&0xkhy4-EC+7jXafUjJi@-yd)H2nCIQZFy;Eq&Xrg&_od+N6(=d z3Po>yTL#KNXxftx?r$x`r55yKe-{m+H}p7Z`%U%-$!KBEA0EJmv;`;<9w`|d_ZcT1 zYaC3UpFN&m=^#>37`%NeFHPtt2!BVPmAexZnkGS=AMKObM?+0&tKoH0+(h;Hdb>7% zvpp078p(ac!d69~uy*(=dG&ihiAul$4b@%=bhn=N@CLL|i&v80$3beLD!0h$@Eyhi zV#zKfZ8ZVr_X~;$8ubV9%PNRy-jik)_PeM{tQ4^o3oJ%fjA8@!7~!s5e(~E>4f=aQ z-QP&(%?l^qGxqOXDt(&NQPz5A$;_jxp-5|LW37PomOhy-JxLf(7C|_j$JZe>od>!U+>g+tvSpQNq-@D*m&yI}t8-1`A|XD^Dvix)A1&w_yRTd# z)$Tc-8L0;Z6)5q{TtH*FvAQH&D<>IwCYfD*9H7*@^jo-BWLe_Rgu4|eOs<~$T!Ret z-IL~vgOkQ0gN{R}R>9gdiV}jT#A;SK?g$bb#7uRx{Gp!*+snGN%$eIfrKi#cC;W4L z2Wh9AePj_~iDcc)I4Y7T-igLL@fW&47Py2D%n|0kN4!7GtD2x(BP4$#%JHUd8koCM zZ)O+2yFR)M(+=RWL+ItRs!!Zd`;9P`FYG-6mmoZ*Cw`Cu*~T8?6yk&5Rf(2uGP9pq ziDF*XO@E0X9y0E1(&B7C1>RZNfkW)`X6$7=^#(){SL~Lq-9$7_FDV16x{D~HsY)F2 zx!7LBx}!7I*Jx6XH|=lnvA++lFdKPbIv5M}y(6c>zF3d-11YY7H+axb>brd%@ui`Y z13%&U#ZIs=0Tv4nz)n{fz)n}rUpxhN)@FwK4!y3OkRW32cwGdY#gJm!*2-LHr3MuWwt0(d;lv0KT;VtUp{dA7C3#UTs6S^v( zs~Qb{G;CLkuQdr`6v0P0PLN-a5urUr#Z}Cm1EdvN(yNz|2tVV2YgJmQ&9jZEOL2~T z)|V7MUl`fT#6XBtf9Kjzlzd>nbQZXx{N0ypQ9O%^<|doM-zU(j&RikrjlP|uwCd%J zv5Cj@ykJm3gjvO9hv>+a+TIu33gNw!y|Ji0l6mQyWs-R0Iq*oNv&g_m9LnJLABuO{ z_%7!{ILV2ExqTM{^t>f!Bd(y(aVskpLLI&v9cWWZT{q3*La)^q!l^2)o?GZnIgj<_RN4&Q$(nsif^6CN-kfd zw8Q~%rTn<34}j5)lYj7&N$xGJgQ2ZP@cj6`ONP$JNymdygr zp7Qi+pAPvfn58-}TrLy!*Gv$)1e0yZ%VLC>;9AEmGuUEbPR(ozM4`yQEZBy6(AJ)V zO=8)TbN5jWqB6m54II&at_`&fUaIco6!tdKI&6lt)u2+!)NnV7sxE`Mp_iZIjfBAz zvw=i_^To1pdfxV{p!jaRlC-Qe@v5!p!)N9YI5KmosGqEctC+U$HUXqL8qcKUS|PAM z^s|&KX=T%j`l8IlezvcM;J93u9|ry~mb+Ptl|qS}V1G}?5BThblBE2qU-Q}!mCD|K zh>D>ddKUDU*ru@kqRxl)b507K0}a?HbuL$l3E(ent~zunulb?+Gw5GmR`Ac=Dky+k z3D`36FNf`a>)8V~qyMv({mx4Tdq_w~pdtxJDFDv@6%4?co};OS0gauZzM-j&!=F|0 zrD!O}M#j&nMr9;vYFXx(W@#knSbzcF$Pkb=x83VMu0;bJZ>3%VqW}S_2*3vd5&#@O z74iYfZ!e0Bh@t?Egsdn)7w@l^IYD*0{n$;V2snQH-k;@%y6pd5CL8|ROI`Og&qX`nxqcEI_MEB-C*|4&o^g@r$r{l8xL zZ`*;tF`u}pC!GK)ISXi^A9iFv3l8A%{S)(l00gbA9e#KP*vRObS^-ioe>w!btec6S zfl(d+Zx(R8`H2fSQwC)H`~q4Sp!{HAt!wZft-+UoL)M)3@PKCG2h^AOFMynY;K)A# z0$v_2t^$q@CIC%lQ~jT+CodT~(n2>N0Vd5j0MiD-zc8c$+UFk_{+N@!gqxA2Tgd^y z3;_;?zrbyx|05irzQ%Tj_V&^MGjKzz|5z}*gx6mr zqqcCg2WY^EnpzkN=<5R*WOS``jsF_~Xo=g3CZNIP0S*4w&JmCQO9C-FU4Rp(5AQu`4h!Rj!zy_>86)vKGfK~x?Jb>%xkG}V7+}%S}`%(bf z65s#;{i%=ue!(x=MB+ca?$>x3Wf(UzfHr0Ycx?O?51#hdcvkifx)v7ytq+4+;-}&Q zp43CYU_$Vx+5sLBmVd(gb?pjV>06WmHwXyu0Rgxpe=1($zeJO^HvX@7`=wv~Pc%fp zSpAEp`z`nSm!0;d7y3^YY?=Sf^6O@J=^6VIQw%VQ|DxtE=O2G@kbPO>myV4;(aF?) ziT>|S`V0TYm(VW_^L|2uX#NxQU+wc=qP}#V`H6~P2+&FY*E9N$J~S@@e*paGWk1Rf zubH348UXmG_WhBW_VVJF&NDwR&iwnu|1tmg?-Rn8@Gsp&e!^3j{H<>Pf&ZP4iI+q# z)&GAIZCd<|=uh?kFJ1sI;a|$w|Acq3`X~4o^W~SYFV)+B!Y)|<6YQTu4KFcYY6t(s ztaSV*%s=+g{hEUy)Uc(Qh4+y5xv{*68+IU|CS+rN$^tT@h1VX z=Wh`FgXZH)rk7f9KbcH?e}n0_l;K`-zEt%3$%z*58=U{7@AZ=Er8LM-D)#W-p!x@) zke5s^B^Z7(aYp?H(;wYI;Fp37FR5OpzW=16iT!OV!1!YGXZgODBrmgvf0D>0{5HuS z&+DJ$RbH~ZOjG^IBAxWxEPqZ~eM#^#N$@8D9pF>y#f#@pWA494nm=yKuTutJQoYR4 z`bmYI@f%eCv#nkx>-@yG%lZxce@@+b`D0$@HvA;3&i&tHzn)~hT!j9KsZswo%zrh< z- \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum -warn ( ) { +warn () { echo "$*" -} +} >&2 -die ( ) { +die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -89,84 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save ( ) { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt index ae329f5..76feb13 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt @@ -472,12 +472,12 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v var next = node while(next.parent() != null) { - ancestors.add(next.parent()) + ancestors.add(next.parent()!!) if(++i == maxDepth) { break } - next = next.parent() + next = next.parent()!! } return ancestors @@ -1004,11 +1004,11 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v return false } - if(parent.parent().tagName() == tagNameLowerCase && (filterFn == null || filterFn(parent.parent()))) { + if(parent.parent()!!.tagName() == tagNameLowerCase && (filterFn == null || filterFn(parent.parent()!!))) { return true } - parent = parent.parent() + parent = parent.parent()!! depth++ } @@ -1115,7 +1115,7 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v protected open fun getTextDirection(topCandidate: Element, doc: Document) { val ancestors = mutableSetOf(topCandidate.parent(), topCandidate) - ancestors.addAll(getNodeAncestors(topCandidate.parent())) + ancestors.addAll(getNodeAncestors(topCandidate.parent()!!)) ancestors.add(doc.body()) ancestors.add(doc.selectFirst("html")) // needed as dir is often set on html tag diff --git a/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt b/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt index a84e782..20f72f3 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt @@ -97,7 +97,7 @@ open class MetadataParser(protected val regEx: RegExUtil = RegExUtil()): Process else if(curTitle.contains(": ")) { // Check if we have an heading containing this exact string, so we // could assume it's the full title. - val match = doc.select("h1, h2").filter { it.wholeText() == curTitle }.size > 0 + val match = doc.select("h1, h2").any { it.wholeText() == curTitle } // If we don't, let's extract the title out of the original title string. if(match == false) { diff --git a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt index 660a2d5..ad72947 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt @@ -130,7 +130,7 @@ open class Preprocessor(protected val regEx: RegExUtil = RegExUtil()) : Processo // all sibling nodes as children of the

until we hit another
// chain. if(replaced) { - val p = br.ownerDocument().createElement("p") + val p = br.ownerDocument()!!.createElement("p") br.replaceWith(p) next = p.nextSibling() diff --git a/src/test/kotlin/net/dankito/readability4j/Readability4JTestBase.kt b/src/test/kotlin/net/dankito/readability4j/Readability4JTestBase.kt index 2adece9..76c4158 100644 --- a/src/test/kotlin/net/dankito/readability4j/Readability4JTestBase.kt +++ b/src/test/kotlin/net/dankito/readability4j/Readability4JTestBase.kt @@ -2,10 +2,13 @@ package net.dankito.readability4j import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper -import com.github.difflib.DiffUtils +import junit.framework.TestCase.assertEquals import net.dankito.readability4j.model.ArticleMetadata import net.dankito.readability4j.model.PageTestData import net.dankito.readability4j.model.ReadabilityOptions +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.safety.Safelist import java.io.BufferedReader import java.io.File import java.io.FileReader @@ -61,19 +64,19 @@ abstract class Readability4JTestBase { val article = underTest.parse() - val expected = getExpectedText(testData) - val actual = getActualText(article, testData) - - assert(actual == expected) { - "Expected:\n${expected}\n\nActual:\n${actual}\n\nDiff:\n${DiffUtils.diff(expected, actual).deltas.joinToString("\n")}" - } + val expected: Document = cleanParseHtml(getExpectedText(testData)) + val actual: Document = cleanParseHtml(getActualText(article, testData)) + assertEquals(expected.html(), actual.html()) testMetadata(testData, article) return article } + private fun cleanParseHtml(text: String?): Document = + Jsoup.parse(Jsoup.clean(text!!, Safelist.relaxed())) + protected open fun createReadability4J(url: String, testData: PageTestData): Readability4J { // Provide one class name to preserve, which we know appears in a few // of the test documents. diff --git a/src/test/kotlin/net/dankito/readability4j/util/TestDataGenerator.kt b/src/test/kotlin/net/dankito/readability4j/util/TestDataGenerator.kt index 1ae9b32..67beb28 100644 --- a/src/test/kotlin/net/dankito/readability4j/util/TestDataGenerator.kt +++ b/src/test/kotlin/net/dankito/readability4j/util/TestDataGenerator.kt @@ -67,7 +67,7 @@ class TestDataGenerator : TestDataGeneratorBase() { val response = executeRequest(request, DefaultCountRetries) - return response.body()?.string() ?: "" + return response.body?.string() ?: "" } catch (e: Exception) { log.error("Could not retrieve response from url $url", e) throw e From bc2583d743e4f840c7eefd8facb2dd825574d6b0 Mon Sep 17 00:00:00 2001 From: NotDroidUser Date: Mon, 24 Mar 2025 21:03:57 -0400 Subject: [PATCH 02/21] refactor in gradle(added toml file for gradle), readme, and some changes by the ide and kotlin things (things more Kotlin less Java) --- .editorconfig | 14 +++++++++ README.md | 10 +++--- build.gradle.kts | 31 +++++++------------ gradle/libs.versions.toml | 27 ++++++++++++++++ settings.gradle | 21 +++++++++++++ .../readability4j/processor/ArticleGrabber.kt | 19 +++++------- .../readability4j/processor/Preprocessor.kt | 2 +- .../readability4j/processor/ProcessorBase.kt | 5 ++- 8 files changed, 89 insertions(+), 40 deletions(-) create mode 100644 .editorconfig create mode 100644 gradle/libs.versions.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3b01cf1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Editorconfig on (https://editorconfig.org/) + +root = true + +[*] +end_of_line = lf +charset = utf-8 +insert_final_newline = true + +[*.java] +indent_size = 4 + +[*.kt] +indent_size = 4 \ No newline at end of file diff --git a/README.md b/README.md index b5cf9a6..acc2b37 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ As mentioned before, this is almost an exact copy of Mozilla's Readability.js. B - + @@ -131,9 +131,9 @@ Overview of which Mozilla‘s Readability.js commit a Readability4J version matc
Readability.js function - Readability4J location + Readability.js functionReadability4J location
_removeScripts() and _prepDocument()
- + + diff --git a/build.gradle.kts b/build.gradle.kts index 47e6ca8..8679c7e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,11 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions + plugins { java - id("org.jetbrains.kotlin.jvm") version "1.9.10" - id("maven-publish") - id("signing") + signing + `maven-publish` + alias(libs.plugins.kotlin.jvm) } group = "net.dankito.readability4j" @@ -10,16 +13,6 @@ version = "1.0.8" val mavenArtifactId = "readability4j" -object versions { - const val kotlin = "1.9.10" - const val slf4j = "2.0.9" - const val jsoup = "1.16.1" - const val jackson = "2.15.2" - const val logback = "1.4.11" - const val diffUtils = "4.15" - const val okHttp = "4.11.0" - const val junit = "4.13.2" -} java { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -31,9 +24,9 @@ repositories { } dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}") - implementation("org.slf4j:slf4j-api:${versions.slf4j}") - implementation("org.jsoup:jsoup:${versions.jsoup}") + implementation(libs.kotlin.stdlib) + implementation(libs.jsoup) + implementation(libs.slf4j.api) testImplementation("junit:junit:${versions.junit}") testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:${versions.jackson}") @@ -44,9 +37,9 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test:${versions.kotlin}") } -tasks.withType { - kotlinOptions { - jvmTarget = "1.8" +tasks.withType> { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..ff27ea5 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,27 @@ +[versions] +slf4j = "2.0.17" +jsoup = "1.19.1" +kotlin = "2.0.0" +jackson = "2.18.3" +logback = "1.5.18" +diffUtils = "4.15" +okHttp = "4.12.0" +gsonVer = "2.9.0" +junitVer = "4.13.2" + +[libraries] +kotlin-stdlib = { group="org.jetbrains.kotlin", name="kotlin-stdlib-jdk8", version.ref = "kotlin" } +gson = { group="com.google.code.gson", name="gson", version.ref = "gsonVer" } +jsoup = { group="org.jsoup", name="jsoup", version.ref = "jsoup" } +slf4j-api= { group="org.slf4j", name="slf4j-api", version.ref="slf4j" } +junit= { group="junit", name="junit", version.ref="junitVer" } +jackson-kotlin= { group="com.fasterxml.jackson.module", name="jackson-module-kotlin", version.ref="jackson" } +java-diff-utils= { group="io.github.java-diff-utils", name="java-diff-utils", version.ref="diffUtils" } +okHttp3= { group="com.squareup.okhttp3", name="okhttp", version.ref="okHttp" } +logback-core= { group="ch.qos.logback", name="logback-core", version.ref="logback" } +logback-classic= { group="ch.qos.logback", name="logback-classic", version.ref="logback" } +kotlin-test= { group="org.jetbrains.kotlin", name="kotlin-test", version.ref="kotlin" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } + diff --git a/settings.gradle b/settings.gradle index abe46f2..04277d6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + rootProject.name = 'Readability4J' diff --git a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt index 76feb13..e4b4fd7 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt @@ -19,20 +19,15 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v companion object { // Element tags to score by default. - val DEFAULT_TAGS_TO_SCORE = Arrays.asList("section", "h2", "h3", "h4", "h5", "h6", "p", "td", "pre") - - - val DIV_TO_P_ELEMS = Arrays.asList("a", "blockquote", "dl", "div", "img", "ol", "p", "pre", "table", "ul", "select") + val DEFAULT_TAGS_TO_SCORE = listOf("section", "h2", "h3", "h4", "h5", "h6", "p", "td", "pre") - val ALTER_TO_DIV_EXCEPTIONS = Arrays.asList("div", "article", "section", "p") + val DIV_TO_P_ELEMS = listOf("a", "blockquote", "dl", "div", "img", "ol", "p", "pre", "table", "ul", "select") - val PRESENTATIONAL_ATTRIBUTES = Arrays.asList("align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", "vspace") - - val DEPRECATED_SIZE_ATTRIBUTE_ELEMS = Arrays.asList("table", "th", "td", "hr", "pre") - - val EMBEDDED_NODES = Arrays.asList("object", "embed", "iframe") - - val DATA_TABLE_DESCENDANTS = Arrays.asList("col", "colgroup", "tfoot", "thead", "th") + val ALTER_TO_DIV_EXCEPTIONS = listOf("div", "article", "section", "p") + val PRESENTATIONAL_ATTRIBUTES = listOf("align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", "vspace") + val DEPRECATED_SIZE_ATTRIBUTE_ELEMS = listOf("table", "th", "td", "hr", "pre") + val EMBEDDED_NODES = listOf("object", "embed", "iframe") + val DATA_TABLE_DESCENDANTS = listOf("col", "colgroup", "tfoot", "thead", "th") private val log = LoggerFactory.getLogger(ArticleGrabber::class.java) diff --git a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt index ad72947..6171415 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt @@ -107,7 +107,7 @@ open class Preprocessor(protected val regEx: RegExUtil = RegExUtil()) : Processo * will become: *
foo
bar

abc

*/ - protected open fun replaceBrs(document: Document, regEx: RegExUtil) { + protected open fun replaceBrs(document: Document) { //removed RegExUtil because it uses the local one anyways document.body().select("br").forEach { br -> var next: Node? = br.nextSibling() diff --git a/src/main/kotlin/net/dankito/readability4j/processor/ProcessorBase.kt b/src/main/kotlin/net/dankito/readability4j/processor/ProcessorBase.kt index b8981c5..91a226d 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/ProcessorBase.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/ProcessorBase.kt @@ -61,9 +61,8 @@ abstract class ProcessorBase { protected open fun nextElement(node: Node?, regEx: RegExUtil): Element? { var next: Node? = node - while(next != null - && (next is Element == false) - && (next is TextNode && regEx.isWhitespace(next.text()))) { + while(next != null && next !is Element && + (next is TextNode && regEx.isWhitespace(next.text()))) { next = next.nextSibling() } From 28163b6a00fa14ab53b410990633940c8b8355ec Mon Sep 17 00:00:00 2001 From: NotDroidUser Date: Sat, 20 Sep 2025 14:54:26 -0400 Subject: [PATCH 03/21] initial 2.0-beta commit --- .gitmodules | 3 + README.md | 107 +- build.gradle.kts | 20 +- gradle/libs.versions.toml | 6 +- .../net/dankito/readability4j/Article.kt | 47 +- .../dankito/readability4j/Readability4J.kt | 162 +- .../extended/Readability4JExtended.kt | 39 +- .../processor/ArticleGrabberExtended.kt | 15 +- .../processor/PostprocessorExtended.kt | 44 +- ...ilExtended.kt => BaseRegexUtilExtended.kt} | 10 +- .../model/ArticleGrabberOptions.kt | 8 +- .../readability4j/model/ArticleMetadata.kt | 46 +- .../readability4j/model/ReadabilityObject.kt | 6 +- .../readability4j/model/ReadabilityOptions.kt | 30 +- .../readability4j/processor/ArticleGrabber.kt | 1164 +- .../readability4j/processor/MetadataParser.kt | 335 +- .../readability4j/processor/Postprocessor.kt | 237 +- .../readability4j/processor/Preprocessor.kt | 174 +- .../readability4j/processor/ProcessorBase.kt | 207 +- .../readability4j/util/BaseRegexUtil.kt | 176 + .../dankito/readability4j/util/RegExUtil.kt | 126 - .../net/dankito/readability4j/util/Util.kt | 19 + .../readability4j/ReadMeCodeExample.java | 6 + .../readability4j/Readability4JTest.kt | 266 +- .../readability4j/Readability4JTestBase.kt | 310 +- .../AdditionalReadability4JTests.kt | 3 +- .../AdditionalReadability4JExtendedTests.kt | 6 +- .../extended/Readability4JExtendedTest.kt | 6 +- .../readability4j/model/PageTestData.kt | 4 +- .../readability4j/model/TestMetadata.kt | 8 + .../readability4j/util/TestDataGenerator.kt | 14 +- .../util/TestDataGeneratorBase.kt | 12 +- .../readability4j/util/TestDataReParser.kt | 16 +- src/test/resources/logback-test.xml | 12 +- .../test-pages/001/expected-metadata.json | 6 - .../resources/test-pages/001/expected.html | 94 - src/test/resources/test-pages/001/source.html | 233 - .../test-pages/002/expected-metadata.json | 6 - .../resources/test-pages/002/expected.html | 361 - src/test/resources/test-pages/002/source.html | 1131 - .../test-pages/ars-1/expected-metadata.json | 6 - .../resources/test-pages/ars-1/expected.html | 52 - .../resources/test-pages/ars-1/source.html | 765 - .../base-url/expected-metadata.json | 6 - .../test-pages/base-url/expected.html | 23 - .../resources/test-pages/base-url/source.html | 43 - .../expected-metadata.json | 6 - .../basic-tags-cleaning/expected.html | 11 - .../basic-tags-cleaning/source.html | 36 - .../test-pages/bbc-1/expected-metadata.json | 6 - .../resources/test-pages/bbc-1/expected.html | 67 - .../resources/test-pages/bbc-1/source.html | 2557 - .../test-pages/blogger/expected-metadata.json | 6 - .../test-pages/blogger/expected.html | 120 - .../resources/test-pages/blogger/source.html | 2064 - .../breitbart/expected-metadata.json | 6 - .../test-pages/breitbart/expected.html | 28 - .../test-pages/breitbart/source.html | 19848 -------- .../bug-1255978/expected-metadata.json | 6 - .../test-pages/bug-1255978/expected.html | 80 - .../test-pages/bug-1255978/source.html | 40907 ---------------- .../buzzfeed-1/expected-metadata.json | 6 - .../test-pages/buzzfeed-1/expected.html | 42 - .../test-pages/buzzfeed-1/source.html | 5294 -- .../clean-links/expected-metadata.json | 6 - .../test-pages/clean-links/expected.html | 14 - .../test-pages/clean-links/source.html | 1863 - .../cnet-svg-classes/expected-metadata.json | 6 - .../test-pages/cnet-svg-classes/expected.html | 105 - .../test-pages/cnet-svg-classes/source.html | 662 - .../test-pages/cnet/expected-metadata.json | 6 - .../resources/test-pages/cnet/expected.html | 36 - .../resources/test-pages/cnet/source.html | 19356 -------- .../test-pages/cnn/expected-metadata.json | 6 - .../resources/test-pages/cnn/expected.html | 46 - src/test/resources/test-pages/cnn/source.html | 4190 -- .../expected-metadata.json | 6 - .../expected.html | 11 - .../comment-inside-script-parsing/source.html | 34 - .../daringfireball-1/expected-metadata.json | 6 - .../test-pages/daringfireball-1/expected.html | 34 - .../test-pages/daringfireball-1/source.html | 151 - .../test-pages/ehow-1/expected-metadata.json | 6 - .../resources/test-pages/ehow-1/expected.html | 180 - .../resources/test-pages/ehow-1/source.html | 934 - .../test-pages/ehow-2/expected-metadata.json | 6 - .../resources/test-pages/ehow-2/expected.html | 160 - .../resources/test-pages/ehow-2/source.html | 1621 - .../embedded-videos/expected-metadata.json | 6 - .../test-pages/embedded-videos/expected.html | 17 - .../test-pages/embedded-videos/source.html | 43 - .../test-pages/gmw/expected-metadata.json | 6 - .../resources/test-pages/gmw/expected.html | 45 - src/test/resources/test-pages/gmw/source.html | 2103 - .../test-pages/heise/expected-metadata.json | 6 - .../resources/test-pages/heise/expected.html | 16 - .../resources/test-pages/heise/source.html | 887 - .../herald-sun-1/expected-extended.html | 35 - .../herald-sun-1/expected-metadata.json | 6 - .../test-pages/herald-sun-1/expected.html | 35 - .../test-pages/herald-sun-1/source.html | 1193 - .../hukumusume/expected-metadata.json | 6 - .../test-pages/hukumusume/expected.html | 53 - .../test-pages/hukumusume/source.html | 273 - .../test-pages/iab-1/expected-metadata.json | 6 - .../resources/test-pages/iab-1/expected.html | 43 - .../resources/test-pages/iab-1/source.html | 1103 - .../test-pages/ietf-1/expected-metadata.json | 6 - .../resources/test-pages/ietf-1/expected.html | 1138 - .../resources/test-pages/ietf-1/source.html | 1269 - .../keep-images/expected-metadata.json | 6 - .../test-pages/keep-images/expected.html | 214 - .../test-pages/keep-images/source.html | 833 - .../la-nacion/expected-extended.html | 34 - .../la-nacion/expected-metadata.json | 6 - .../test-pages/la-nacion/expected.html | 32 - .../test-pages/la-nacion/source.html | 700 - .../lemonde-1/expected-metadata.json | 6 - .../test-pages/lemonde-1/expected.html | 42 - .../test-pages/lemonde-1/source.html | 1117 - .../liberation-1/expected-metadata.json | 6 - .../test-pages/liberation-1/expected.html | 15 - .../test-pages/liberation-1/source.html | 1803 - .../expected-metadata.json | 6 - .../expected.html | 83 - .../lifehacker-post-comment-load/source.html | 1305 - .../lifehacker-working/expected-metadata.json | 6 - .../lifehacker-working/expected.html | 83 - .../test-pages/lifehacker-working/source.html | 1241 - .../links-in-tables/expected-metadata.json | 6 - .../test-pages/links-in-tables/expected.html | 110 - .../test-pages/links-in-tables/source.html | 3165 -- .../test-pages/lwn-1/expected-metadata.json | 6 - .../resources/test-pages/lwn-1/expected.html | 573 - .../resources/test-pages/lwn-1/source.html | 820 - .../medium-1/expected-metadata.json | 6 - .../test-pages/medium-1/expected.html | 137 - .../resources/test-pages/medium-1/source.html | 705 - .../medium-2/expected-metadata.json | 6 - .../test-pages/medium-2/expected.html | 40 - .../resources/test-pages/medium-2/source.html | 14 - .../medium-3/expected-metadata.json | 6 - .../test-pages/medium-3/expected.html | 383 - .../resources/test-pages/medium-3/source.html | 4815 -- .../missing-paragraphs/expected-metadata.json | 6 - .../missing-paragraphs/expected.html | 8 - .../test-pages/missing-paragraphs/source.html | 64 - .../mozilla-1/expected-extended.html | 79 - .../mozilla-1/expected-metadata.json | 6 - .../test-pages/mozilla-1/expected.html | 79 - .../test-pages/mozilla-1/source.html | 1348 - .../mozilla-2/expected-metadata.json | 6 - .../test-pages/mozilla-2/expected.html | 33 - .../test-pages/mozilla-2/source.html | 408 - .../test-pages/msn/expected-extended.html | 13 - .../test-pages/msn/expected-metadata.json | 6 - .../resources/test-pages/msn/expected.html | 13 - src/test/resources/test-pages/msn/source.html | 13010 ----- .../normalize-spaces/expected-metadata.json | 6 - .../test-pages/normalize-spaces/expected.html | 8 - .../test-pages/normalize-spaces/source.html | 35 - .../nytimes-1/expected-metadata.json | 6 - .../test-pages/nytimes-1/expected.html | 39 - .../test-pages/nytimes-1/source.html | 5530 --- .../nytimes-2/expected-metadata.json | 6 - .../test-pages/nytimes-2/expected.html | 43 - .../test-pages/nytimes-2/source.html | 5236 -- .../test-pages/pixnet/expected-metadata.json | 6 - .../resources/test-pages/pixnet/expected.html | 133 - .../resources/test-pages/pixnet/source.html | 4372 -- .../test-pages/qq/expected-metadata.json | 6 - .../resources/test-pages/qq/expected.html | 34 - src/test/resources/test-pages/qq/source.html | 7280 --- .../remove-extra-brs/expected-metadata.json | 6 - .../test-pages/remove-extra-brs/expected.html | 11 - .../test-pages/remove-extra-brs/source.html | 32 - .../expected-metadata.json | 6 - .../remove-extra-paragraphs/expected.html | 11 - .../remove-extra-paragraphs/source.html | 41 - .../remove-script-tags/expected-metadata.json | 6 - .../remove-script-tags/expected.html | 11 - .../test-pages/remove-script-tags/source.html | 43 - .../expected-metadata.json | 6 - .../reordering-paragraphs/expected.html | 6 - .../reordering-paragraphs/source.html | 34 - .../replace-brs/expected-metadata.json | 6 - .../test-pages/replace-brs/expected.html | 11 - .../test-pages/replace-brs/source.html | 28 - .../replace-font-tags/expected-metadata.json | 6 - .../replace-font-tags/expected.html | 8 - .../test-pages/replace-font-tags/source.html | 28 - .../test-pages/rtl-1/expected-metadata.json | 6 - .../resources/test-pages/rtl-1/expected.html | 10 - .../resources/test-pages/rtl-1/source.html | 26 - .../test-pages/rtl-2/expected-metadata.json | 6 - .../resources/test-pages/rtl-2/expected.html | 10 - .../resources/test-pages/rtl-2/source.html | 26 - .../test-pages/rtl-3/expected-metadata.json | 6 - .../resources/test-pages/rtl-3/expected.html | 10 - .../resources/test-pages/rtl-3/source.html | 26 - .../test-pages/rtl-4/expected-metadata.json | 6 - .../resources/test-pages/rtl-4/expected.html | 10 - .../resources/test-pages/rtl-4/source.html | 26 - .../test-pages/salon-1/expected-metadata.json | 6 - .../test-pages/salon-1/expected.html | 41 - .../resources/test-pages/salon-1/source.html | 2513 - .../simplyfound-1/expected-metadata.json | 6 - .../test-pages/simplyfound-1/expected.html | 11 - .../test-pages/simplyfound-1/source.html | 426 - .../social-buttons/expected-metadata.json | 6 - .../test-pages/social-buttons/expected.html | 10 - .../test-pages/social-buttons/source.html | 54 - .../style-tags-removal/expected-metadata.json | 6 - .../style-tags-removal/expected.html | 8 - .../test-pages/style-tags-removal/source.html | 42 - .../svg-parsing/expected-metadata.json | 6 - .../test-pages/svg-parsing/expected.html | 17 - .../test-pages/svg-parsing/source.html | 44 - .../expected-metadata.json | 6 - .../table-style-attributes/expected.html | 86 - .../table-style-attributes/source.html | 165 - .../telegraph/expected-metadata.json | 6 - .../test-pages/telegraph/expected.html | 45 - .../test-pages/telegraph/source.html | 1821 - .../expected-metadata.json | 6 - .../title-and-h1-discrepancy/expected.html | 7 - .../title-and-h1-discrepancy/source.html | 30 - .../test-pages/tmz-1/expected-metadata.json | 6 - .../resources/test-pages/tmz-1/expected.html | 16 - .../resources/test-pages/tmz-1/source.html | 1528 - .../test-pages/tumblr/expected-metadata.json | 6 - .../resources/test-pages/tumblr/expected.html | 10 - .../resources/test-pages/tumblr/source.html | 793 - .../test-pages/wapo-1/expected-metadata.json | 6 - .../resources/test-pages/wapo-1/expected.html | 50 - .../resources/test-pages/wapo-1/source.html | 1560 - .../test-pages/wapo-2/expected-extended.html | 41 - .../test-pages/wapo-2/expected-metadata.json | 6 - .../resources/test-pages/wapo-2/expected.html | 35 - .../resources/test-pages/wapo-2/source.html | 1038 - .../test-pages/webmd-1/expected-metadata.json | 6 - .../test-pages/webmd-1/expected.html | 16 - .../resources/test-pages/webmd-1/source.html | 2411 - .../test-pages/webmd-2/expected-metadata.json | 6 - .../test-pages/webmd-2/expected.html | 16 - .../resources/test-pages/webmd-2/source.html | 1299 - .../test-pages/wikia/expected-metadata.json | 6 - .../resources/test-pages/wikia/expected.html | 18 - .../resources/test-pages/wikia/source.html | 19998 -------- .../wikipedia/expected-metadata.json | 6 - .../test-pages/wikipedia/expected.html | 243 - .../test-pages/wikipedia/source.html | 1652 - .../wordpress/expected-metadata.json | 6 - .../test-pages/wordpress/expected.html | 20 - .../test-pages/wordpress/source.html | 2228 - .../test-pages/yahoo-1/expected-metadata.json | 6 - .../test-pages/yahoo-1/expected.html | 56 - .../resources/test-pages/yahoo-1/source.html | 14670 ------ .../test-pages/yahoo-2/expected-metadata.json | 6 - .../test-pages/yahoo-2/expected.html | 37 - .../resources/test-pages/yahoo-2/source.html | 20539 -------- .../test-pages/yahoo-3/expected-metadata.json | 6 - .../test-pages/yahoo-3/expected.html | 54 - .../resources/test-pages/yahoo-3/source.html | 14949 ------ .../test-pages/yahoo-4/expected-metadata.json | 6 - .../test-pages/yahoo-4/expected.html | 3 - .../resources/test-pages/yahoo-4/source.html | 1234 - .../test-pages/youth/expected-metadata.json | 6 - .../resources/test-pages/youth/expected.html | 38 - .../resources/test-pages/youth/source.html | 10982 ----- 270 files changed, 2604 insertions(+), 270199 deletions(-) create mode 100644 .gitmodules rename src/main/kotlin/net/dankito/readability4j/extended/util/{RegExUtilExtended.kt => BaseRegexUtilExtended.kt} (88%) create mode 100644 src/main/kotlin/net/dankito/readability4j/util/BaseRegexUtil.kt delete mode 100644 src/main/kotlin/net/dankito/readability4j/util/RegExUtil.kt create mode 100644 src/main/kotlin/net/dankito/readability4j/util/Util.kt create mode 100644 src/test/kotlin/net/dankito/readability4j/model/TestMetadata.kt delete mode 100644 src/test/resources/test-pages/001/expected-metadata.json delete mode 100644 src/test/resources/test-pages/001/expected.html delete mode 100644 src/test/resources/test-pages/001/source.html delete mode 100644 src/test/resources/test-pages/002/expected-metadata.json delete mode 100644 src/test/resources/test-pages/002/expected.html delete mode 100644 src/test/resources/test-pages/002/source.html delete mode 100644 src/test/resources/test-pages/ars-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/ars-1/expected.html delete mode 100644 src/test/resources/test-pages/ars-1/source.html delete mode 100644 src/test/resources/test-pages/base-url/expected-metadata.json delete mode 100644 src/test/resources/test-pages/base-url/expected.html delete mode 100644 src/test/resources/test-pages/base-url/source.html delete mode 100644 src/test/resources/test-pages/basic-tags-cleaning/expected-metadata.json delete mode 100644 src/test/resources/test-pages/basic-tags-cleaning/expected.html delete mode 100644 src/test/resources/test-pages/basic-tags-cleaning/source.html delete mode 100644 src/test/resources/test-pages/bbc-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/bbc-1/expected.html delete mode 100644 src/test/resources/test-pages/bbc-1/source.html delete mode 100644 src/test/resources/test-pages/blogger/expected-metadata.json delete mode 100644 src/test/resources/test-pages/blogger/expected.html delete mode 100644 src/test/resources/test-pages/blogger/source.html delete mode 100644 src/test/resources/test-pages/breitbart/expected-metadata.json delete mode 100644 src/test/resources/test-pages/breitbart/expected.html delete mode 100644 src/test/resources/test-pages/breitbart/source.html delete mode 100644 src/test/resources/test-pages/bug-1255978/expected-metadata.json delete mode 100644 src/test/resources/test-pages/bug-1255978/expected.html delete mode 100644 src/test/resources/test-pages/bug-1255978/source.html delete mode 100644 src/test/resources/test-pages/buzzfeed-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/buzzfeed-1/expected.html delete mode 100644 src/test/resources/test-pages/buzzfeed-1/source.html delete mode 100644 src/test/resources/test-pages/clean-links/expected-metadata.json delete mode 100644 src/test/resources/test-pages/clean-links/expected.html delete mode 100644 src/test/resources/test-pages/clean-links/source.html delete mode 100644 src/test/resources/test-pages/cnet-svg-classes/expected-metadata.json delete mode 100644 src/test/resources/test-pages/cnet-svg-classes/expected.html delete mode 100644 src/test/resources/test-pages/cnet-svg-classes/source.html delete mode 100644 src/test/resources/test-pages/cnet/expected-metadata.json delete mode 100644 src/test/resources/test-pages/cnet/expected.html delete mode 100644 src/test/resources/test-pages/cnet/source.html delete mode 100644 src/test/resources/test-pages/cnn/expected-metadata.json delete mode 100644 src/test/resources/test-pages/cnn/expected.html delete mode 100644 src/test/resources/test-pages/cnn/source.html delete mode 100644 src/test/resources/test-pages/comment-inside-script-parsing/expected-metadata.json delete mode 100644 src/test/resources/test-pages/comment-inside-script-parsing/expected.html delete mode 100644 src/test/resources/test-pages/comment-inside-script-parsing/source.html delete mode 100644 src/test/resources/test-pages/daringfireball-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/daringfireball-1/expected.html delete mode 100644 src/test/resources/test-pages/daringfireball-1/source.html delete mode 100644 src/test/resources/test-pages/ehow-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/ehow-1/expected.html delete mode 100644 src/test/resources/test-pages/ehow-1/source.html delete mode 100644 src/test/resources/test-pages/ehow-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/ehow-2/expected.html delete mode 100644 src/test/resources/test-pages/ehow-2/source.html delete mode 100644 src/test/resources/test-pages/embedded-videos/expected-metadata.json delete mode 100644 src/test/resources/test-pages/embedded-videos/expected.html delete mode 100644 src/test/resources/test-pages/embedded-videos/source.html delete mode 100644 src/test/resources/test-pages/gmw/expected-metadata.json delete mode 100644 src/test/resources/test-pages/gmw/expected.html delete mode 100644 src/test/resources/test-pages/gmw/source.html delete mode 100644 src/test/resources/test-pages/heise/expected-metadata.json delete mode 100644 src/test/resources/test-pages/heise/expected.html delete mode 100644 src/test/resources/test-pages/heise/source.html delete mode 100644 src/test/resources/test-pages/herald-sun-1/expected-extended.html delete mode 100644 src/test/resources/test-pages/herald-sun-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/herald-sun-1/expected.html delete mode 100644 src/test/resources/test-pages/herald-sun-1/source.html delete mode 100644 src/test/resources/test-pages/hukumusume/expected-metadata.json delete mode 100644 src/test/resources/test-pages/hukumusume/expected.html delete mode 100644 src/test/resources/test-pages/hukumusume/source.html delete mode 100644 src/test/resources/test-pages/iab-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/iab-1/expected.html delete mode 100644 src/test/resources/test-pages/iab-1/source.html delete mode 100644 src/test/resources/test-pages/ietf-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/ietf-1/expected.html delete mode 100644 src/test/resources/test-pages/ietf-1/source.html delete mode 100644 src/test/resources/test-pages/keep-images/expected-metadata.json delete mode 100644 src/test/resources/test-pages/keep-images/expected.html delete mode 100644 src/test/resources/test-pages/keep-images/source.html delete mode 100644 src/test/resources/test-pages/la-nacion/expected-extended.html delete mode 100644 src/test/resources/test-pages/la-nacion/expected-metadata.json delete mode 100644 src/test/resources/test-pages/la-nacion/expected.html delete mode 100644 src/test/resources/test-pages/la-nacion/source.html delete mode 100644 src/test/resources/test-pages/lemonde-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/lemonde-1/expected.html delete mode 100644 src/test/resources/test-pages/lemonde-1/source.html delete mode 100644 src/test/resources/test-pages/liberation-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/liberation-1/expected.html delete mode 100644 src/test/resources/test-pages/liberation-1/source.html delete mode 100644 src/test/resources/test-pages/lifehacker-post-comment-load/expected-metadata.json delete mode 100644 src/test/resources/test-pages/lifehacker-post-comment-load/expected.html delete mode 100644 src/test/resources/test-pages/lifehacker-post-comment-load/source.html delete mode 100644 src/test/resources/test-pages/lifehacker-working/expected-metadata.json delete mode 100644 src/test/resources/test-pages/lifehacker-working/expected.html delete mode 100644 src/test/resources/test-pages/lifehacker-working/source.html delete mode 100644 src/test/resources/test-pages/links-in-tables/expected-metadata.json delete mode 100644 src/test/resources/test-pages/links-in-tables/expected.html delete mode 100644 src/test/resources/test-pages/links-in-tables/source.html delete mode 100644 src/test/resources/test-pages/lwn-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/lwn-1/expected.html delete mode 100644 src/test/resources/test-pages/lwn-1/source.html delete mode 100644 src/test/resources/test-pages/medium-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/medium-1/expected.html delete mode 100644 src/test/resources/test-pages/medium-1/source.html delete mode 100644 src/test/resources/test-pages/medium-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/medium-2/expected.html delete mode 100644 src/test/resources/test-pages/medium-2/source.html delete mode 100644 src/test/resources/test-pages/medium-3/expected-metadata.json delete mode 100644 src/test/resources/test-pages/medium-3/expected.html delete mode 100644 src/test/resources/test-pages/medium-3/source.html delete mode 100644 src/test/resources/test-pages/missing-paragraphs/expected-metadata.json delete mode 100644 src/test/resources/test-pages/missing-paragraphs/expected.html delete mode 100644 src/test/resources/test-pages/missing-paragraphs/source.html delete mode 100644 src/test/resources/test-pages/mozilla-1/expected-extended.html delete mode 100644 src/test/resources/test-pages/mozilla-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/mozilla-1/expected.html delete mode 100644 src/test/resources/test-pages/mozilla-1/source.html delete mode 100644 src/test/resources/test-pages/mozilla-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/mozilla-2/expected.html delete mode 100644 src/test/resources/test-pages/mozilla-2/source.html delete mode 100644 src/test/resources/test-pages/msn/expected-extended.html delete mode 100644 src/test/resources/test-pages/msn/expected-metadata.json delete mode 100644 src/test/resources/test-pages/msn/expected.html delete mode 100644 src/test/resources/test-pages/msn/source.html delete mode 100644 src/test/resources/test-pages/normalize-spaces/expected-metadata.json delete mode 100644 src/test/resources/test-pages/normalize-spaces/expected.html delete mode 100644 src/test/resources/test-pages/normalize-spaces/source.html delete mode 100644 src/test/resources/test-pages/nytimes-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/nytimes-1/expected.html delete mode 100644 src/test/resources/test-pages/nytimes-1/source.html delete mode 100644 src/test/resources/test-pages/nytimes-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/nytimes-2/expected.html delete mode 100644 src/test/resources/test-pages/nytimes-2/source.html delete mode 100644 src/test/resources/test-pages/pixnet/expected-metadata.json delete mode 100644 src/test/resources/test-pages/pixnet/expected.html delete mode 100644 src/test/resources/test-pages/pixnet/source.html delete mode 100644 src/test/resources/test-pages/qq/expected-metadata.json delete mode 100644 src/test/resources/test-pages/qq/expected.html delete mode 100644 src/test/resources/test-pages/qq/source.html delete mode 100644 src/test/resources/test-pages/remove-extra-brs/expected-metadata.json delete mode 100644 src/test/resources/test-pages/remove-extra-brs/expected.html delete mode 100644 src/test/resources/test-pages/remove-extra-brs/source.html delete mode 100644 src/test/resources/test-pages/remove-extra-paragraphs/expected-metadata.json delete mode 100644 src/test/resources/test-pages/remove-extra-paragraphs/expected.html delete mode 100644 src/test/resources/test-pages/remove-extra-paragraphs/source.html delete mode 100644 src/test/resources/test-pages/remove-script-tags/expected-metadata.json delete mode 100644 src/test/resources/test-pages/remove-script-tags/expected.html delete mode 100644 src/test/resources/test-pages/remove-script-tags/source.html delete mode 100644 src/test/resources/test-pages/reordering-paragraphs/expected-metadata.json delete mode 100644 src/test/resources/test-pages/reordering-paragraphs/expected.html delete mode 100644 src/test/resources/test-pages/reordering-paragraphs/source.html delete mode 100644 src/test/resources/test-pages/replace-brs/expected-metadata.json delete mode 100644 src/test/resources/test-pages/replace-brs/expected.html delete mode 100644 src/test/resources/test-pages/replace-brs/source.html delete mode 100644 src/test/resources/test-pages/replace-font-tags/expected-metadata.json delete mode 100644 src/test/resources/test-pages/replace-font-tags/expected.html delete mode 100644 src/test/resources/test-pages/replace-font-tags/source.html delete mode 100644 src/test/resources/test-pages/rtl-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/rtl-1/expected.html delete mode 100644 src/test/resources/test-pages/rtl-1/source.html delete mode 100644 src/test/resources/test-pages/rtl-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/rtl-2/expected.html delete mode 100644 src/test/resources/test-pages/rtl-2/source.html delete mode 100644 src/test/resources/test-pages/rtl-3/expected-metadata.json delete mode 100644 src/test/resources/test-pages/rtl-3/expected.html delete mode 100644 src/test/resources/test-pages/rtl-3/source.html delete mode 100644 src/test/resources/test-pages/rtl-4/expected-metadata.json delete mode 100644 src/test/resources/test-pages/rtl-4/expected.html delete mode 100644 src/test/resources/test-pages/rtl-4/source.html delete mode 100644 src/test/resources/test-pages/salon-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/salon-1/expected.html delete mode 100644 src/test/resources/test-pages/salon-1/source.html delete mode 100644 src/test/resources/test-pages/simplyfound-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/simplyfound-1/expected.html delete mode 100644 src/test/resources/test-pages/simplyfound-1/source.html delete mode 100644 src/test/resources/test-pages/social-buttons/expected-metadata.json delete mode 100644 src/test/resources/test-pages/social-buttons/expected.html delete mode 100644 src/test/resources/test-pages/social-buttons/source.html delete mode 100644 src/test/resources/test-pages/style-tags-removal/expected-metadata.json delete mode 100644 src/test/resources/test-pages/style-tags-removal/expected.html delete mode 100644 src/test/resources/test-pages/style-tags-removal/source.html delete mode 100644 src/test/resources/test-pages/svg-parsing/expected-metadata.json delete mode 100644 src/test/resources/test-pages/svg-parsing/expected.html delete mode 100644 src/test/resources/test-pages/svg-parsing/source.html delete mode 100644 src/test/resources/test-pages/table-style-attributes/expected-metadata.json delete mode 100644 src/test/resources/test-pages/table-style-attributes/expected.html delete mode 100644 src/test/resources/test-pages/table-style-attributes/source.html delete mode 100644 src/test/resources/test-pages/telegraph/expected-metadata.json delete mode 100644 src/test/resources/test-pages/telegraph/expected.html delete mode 100644 src/test/resources/test-pages/telegraph/source.html delete mode 100644 src/test/resources/test-pages/title-and-h1-discrepancy/expected-metadata.json delete mode 100644 src/test/resources/test-pages/title-and-h1-discrepancy/expected.html delete mode 100644 src/test/resources/test-pages/title-and-h1-discrepancy/source.html delete mode 100644 src/test/resources/test-pages/tmz-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/tmz-1/expected.html delete mode 100644 src/test/resources/test-pages/tmz-1/source.html delete mode 100644 src/test/resources/test-pages/tumblr/expected-metadata.json delete mode 100644 src/test/resources/test-pages/tumblr/expected.html delete mode 100644 src/test/resources/test-pages/tumblr/source.html delete mode 100644 src/test/resources/test-pages/wapo-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/wapo-1/expected.html delete mode 100644 src/test/resources/test-pages/wapo-1/source.html delete mode 100644 src/test/resources/test-pages/wapo-2/expected-extended.html delete mode 100644 src/test/resources/test-pages/wapo-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/wapo-2/expected.html delete mode 100644 src/test/resources/test-pages/wapo-2/source.html delete mode 100644 src/test/resources/test-pages/webmd-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/webmd-1/expected.html delete mode 100644 src/test/resources/test-pages/webmd-1/source.html delete mode 100644 src/test/resources/test-pages/webmd-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/webmd-2/expected.html delete mode 100644 src/test/resources/test-pages/webmd-2/source.html delete mode 100644 src/test/resources/test-pages/wikia/expected-metadata.json delete mode 100644 src/test/resources/test-pages/wikia/expected.html delete mode 100644 src/test/resources/test-pages/wikia/source.html delete mode 100644 src/test/resources/test-pages/wikipedia/expected-metadata.json delete mode 100644 src/test/resources/test-pages/wikipedia/expected.html delete mode 100644 src/test/resources/test-pages/wikipedia/source.html delete mode 100644 src/test/resources/test-pages/wordpress/expected-metadata.json delete mode 100644 src/test/resources/test-pages/wordpress/expected.html delete mode 100644 src/test/resources/test-pages/wordpress/source.html delete mode 100644 src/test/resources/test-pages/yahoo-1/expected-metadata.json delete mode 100644 src/test/resources/test-pages/yahoo-1/expected.html delete mode 100644 src/test/resources/test-pages/yahoo-1/source.html delete mode 100644 src/test/resources/test-pages/yahoo-2/expected-metadata.json delete mode 100644 src/test/resources/test-pages/yahoo-2/expected.html delete mode 100644 src/test/resources/test-pages/yahoo-2/source.html delete mode 100644 src/test/resources/test-pages/yahoo-3/expected-metadata.json delete mode 100644 src/test/resources/test-pages/yahoo-3/expected.html delete mode 100644 src/test/resources/test-pages/yahoo-3/source.html delete mode 100644 src/test/resources/test-pages/yahoo-4/expected-metadata.json delete mode 100644 src/test/resources/test-pages/yahoo-4/expected.html delete mode 100644 src/test/resources/test-pages/yahoo-4/source.html delete mode 100644 src/test/resources/test-pages/youth/expected-metadata.json delete mode 100644 src/test/resources/test-pages/youth/expected.html delete mode 100644 src/test/resources/test-pages/youth/source.html diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d536530 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/test/resources/readability"] + path = src/test/resources/readability + url = https://github.com/mozilla/readability/ diff --git a/README.md b/README.md index acc2b37..eb8498a 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,13 @@ Maven: ``` +Version 2.0.0 is still in development so you must build from source ## Usage ```java -String url = ...; -String html = ...; +String url = "somepage.com"; +String html = "Some Bloated Article html source"; Readability4J readability4J = new Readability4J(url, html); // url is just needed to resolve relative urls Article article = readability4J.parse(); @@ -47,34 +48,69 @@ String byline = article.getByline(); String excerpt = article.getExcerpt(); ``` -## Readability4J and Readability4JExtended +```kotlin -With Readability4J class I wanted to stick close to Mozilla's Readability to keep compatibility. +val url = "somepage.com" +val html = "Some Bloated Article html source" -But during development I found some handy features not supported by Readability, e. g. copying url from data-src -attribute to <img src="" /> to display lazy loading images, using <head><base>'s href value for resolving -relative urls and a -better -detection of -which -images to keep in output. +val readability4J = Readability4J(url, html) // url is just needed to resolve relative urls +val article = readability4J.parse() -These features I implemented in Readability4JExtended. +// returns extracted content in a
element +val extractedContentHtml = article.getContent() +// to get content wrapped in tags and encoding set to UTF-8, see chapter 'Output encoding' +val extractedContentHtmlWithUtf8Encoding = article.getContentWithUtf8Encoding() +val extractedContentPlainText = article.getTextContent() +val title = article.getTitle() +val byline = article.getByline() +val excerpt = article.getExcerpt() -If you want to use it, simply instantiate with (the rest of the code stays the same): +``` -
-Readability4J readability4J = new Readability4JExtended(url, html);
-Article article = readability4J.parse();
-
+# Why i can't use Readability4JExtended now? + +Basically as you have seen in code, it is divided in 4 classes Preprocessor, MetadataParser, ArticleGrabber and PostProcessor + +Preprocessor is the code that work with the HTML, removing tags like script, style, successive br tags and change font tags into span tags and also unwraps no-script tag images +MetadataParser parses meta tags for info and ld+json before scripts are removed +ArticleGrabber is the one is where magic is done +PostProcessor is where the a tags get from relative to native + +As readability code changed a lot from the latest commit, had first updated Readability4J code base to make the updating process the less stressfully, yet you can now set the classes there changing their overridable methods, also if the only change is the regex you can instantiate it changing only the BaseRegexUtil class regex and it will work anyways + +```java +String url = "some-specific-page.com"; +String html = "Some Bloated Article html source that needs extra steps"; + +Readability4J readability4J = Readability4J(url, html); +ArticleGrabberExtended extended = new ArticleGrabberExtended(readability4J.getOptions(),new BaseRegexUtilExtended()); +readability4J.setArticleGrabber(extended); +``` + +```kotlin +val url = "some-specific-page.com" +val html = "Some Bloated Article html source that needs extra steps" + +val readability4J = Readability4J(url, html) +readability4J.articleGrabber = ArticleGrabberExtended(readability4J.options,BaseRegexUtilExtended()) +``` + + ## Output encoding As users noted (see Issue [#1](https://github.com/dankito/Readability4J/issues/1) and [#2](https://github.com/dankito/Readability4J/issues/2)) by default no encoding is applied to Readability4J's output resulting in incorrect display of non-ASCII characters. -The reason is like Readability.js Readability4J returns its output in a <div> element, and the only way to set the -encoding in HTML is in a <head><meta charset=""> tag. +The reason is like Readability.js Readability4J returns its output in a `
` element, and the only way to set the encoding in HTML is in a ` ` tag. So I added these convenience methods to Article class @@ -86,6 +122,14 @@ String contentWithDocumentsCharsetOrUtf8 = article.getContentWithDocumentsCharse String contentHtmlWithCustomEncoding = article.getContentWithEncoding("ISO-8859-1"); ``` +```kotlin +var contentHtmlWithUtf8Encoding = article.getContentWithUtf8Encoding() +// or (tries to apply site's charset, if set, or if not uses UTF-8 as fallback +var contentWithDocumentsCharsetOrUtf8 = article.getContentWithDocumentsCharsetOrUtf8() +// or +var contentHtmlWithCustomEncoding = article.getContentWithEncoding("ISO-8859-1") +``` + which wrap the content in ``` @@ -101,7 +145,7 @@ which wrap the content in ## Compatibility with Mozilla‘s Readability.js -As mentioned before, this is almost an exact copy of Mozilla's Readability.js. But since I didn't find the original code very readable itself, I extracted some parts from the 2000 lines of code into a new classes: +As mentioned before, this is almost an exact copy of Mozilla's Readability.js. But since the code in only one file can be almost unreadable, I extracted some parts from the 2000+ lines of code into a new classes:
Version - Commit - Date + VersionCommitDate
1.0
@@ -109,8 +153,8 @@ As mentioned before, this is almost an exact copy of Mozilla's Readability.js. B - - + + @@ -121,11 +165,16 @@ As mentioned before, this is almost an exact copy of Mozilla's Readability.js. B - - + + + + + +
Readability4J location
_removeScripts() and _prepDocument()Preprocessor.prepareDocument()_unwrapNoscriptImages(), _removeScripts() and _prepDocument()Preprocessor.unwrapNoscriptImages(), Preprocessor.removeScripts() and Preprocessor.prepDocument()
_grabArticle()Postprocessor.postProcessContent()
_getArticleMetadata()MetadataParser.getArticleMetadata()_getJSONLD(),_getArticleMetadata()MetadataParser.getJSONLD(), MetadataParser.getArticleMetadata()
_getJSONLD(),_getArticleMetadata()MetadataParser.getJSONLD(), MetadataParser.getArticleMetadata()
+I added some utils on Util.kt so the nodes are logged as the Javascript, also put the latest compatible Jackson Overview of which Mozilla‘s Readability.js commit a Readability4J version matches: @@ -145,11 +194,19 @@ Overview of which Mozilla‘s Readability.js commit a Readability4J version matc 834672e 02/27/18 + + 2.0.0-beta + almost all test from 04fd32f works + 29/05/25 + +## Testing +I had added readability.js as a submodule so it will be updated with their latest tests, also i dont get their results for done, i do a call to the readability.js inside HTMLUnit, with some regex changes, syntactic [see rhino compat](https://mozilla.github.io/rhino/compat/engines.html#ES2015-syntax-spread-syntax-for-iterable-objects) and non syntactic as it can run as a function than a class + ## Extensibility -I tried to create the library as extensible as possible. All above mentioned classes can be overwritten and passed to Readability4J's constructor. +I tried to maintain the library as extensible as possible. All above mentioned classes can be overwritten and passed to Readability4J's as a variable assignment. ## Logging diff --git a/build.gradle.kts b/build.gradle.kts index 8679c7e..ee58a51 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "net.dankito.readability4j" -version = "1.0.8" +version = "2.0.0-beta" val mavenArtifactId = "readability4j" @@ -27,14 +27,16 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.jsoup) implementation(libs.slf4j.api) - - testImplementation("junit:junit:${versions.junit}") - testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:${versions.jackson}") - testImplementation("io.github.java-diff-utils:java-diff-utils:${versions.diffUtils}") - testImplementation("com.squareup.okhttp3:okhttp:${versions.okHttp}") - testImplementation("ch.qos.logback:logback-core:${versions.logback}") - testImplementation("ch.qos.logback:logback-classic:${versions.logback}") - testImplementation("org.jetbrains.kotlin:kotlin-test:${versions.kotlin}") + implementation(libs.jackson.kotlin) //for LD-Json + + testImplementation(libs.junit) + testImplementation(libs.htmlunit) + testImplementation(libs.jackson.kotlin) + testImplementation(libs.java.diff.utils) + testImplementation(libs.okHttp3) + testImplementation(libs.logback.core) + testImplementation(libs.logback.classic) + testImplementation(libs.kotlin.test) } tasks.withType> { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff27ea5..c6574eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,19 +2,19 @@ slf4j = "2.0.17" jsoup = "1.19.1" kotlin = "2.0.0" -jackson = "2.18.3" +jackson = "2.13.5" logback = "1.5.18" diffUtils = "4.15" okHttp = "4.12.0" -gsonVer = "2.9.0" junitVer = "4.13.2" +htmlunitVer = "4.11.1" [libraries] kotlin-stdlib = { group="org.jetbrains.kotlin", name="kotlin-stdlib-jdk8", version.ref = "kotlin" } -gson = { group="com.google.code.gson", name="gson", version.ref = "gsonVer" } jsoup = { group="org.jsoup", name="jsoup", version.ref = "jsoup" } slf4j-api= { group="org.slf4j", name="slf4j-api", version.ref="slf4j" } junit= { group="junit", name="junit", version.ref="junitVer" } +htmlunit= { group="org.htmlunit", name="htmlunit", version.ref="htmlunitVer" } jackson-kotlin= { group="com.fasterxml.jackson.module", name="jackson-module-kotlin", version.ref="jackson" } java-diff-utils= { group="io.github.java-diff-utils", name="java-diff-utils", version.ref="diffUtils" } okHttp3= { group="com.squareup.okhttp3", name="okhttp", version.ref="okHttp" } diff --git a/src/main/kotlin/net/dankito/readability4j/Article.kt b/src/main/kotlin/net/dankito/readability4j/Article.kt index a8d4a26..e91ee79 100644 --- a/src/main/kotlin/net/dankito/readability4j/Article.kt +++ b/src/main/kotlin/net/dankito/readability4j/Article.kt @@ -7,27 +7,45 @@ open class Article( /** * Original uri object that was passed to constructor + * that has no usage and also is in the same context that the Readability4J is called */ - val uri: String - ) { + constructor(uri:String):this(){ + this.uri=uri + } + @Deprecated("This has no sense as you has the url in the context you call Readability4J", + level = DeprecationLevel.WARNING) + var uri: String="" /** * Article title */ var title: String? = null + /** + * The actual html object of the article + * */ var articleContent: Element? = null + /** + * Content lang (from html tag) default to empty + */ + var lang:String? = null + /** * HTML string of processed article content in a <div> element. * - * Therefore no encoding is applied, see [contentWithUtf8Encoding] or issue - * [https://github.com/dankito/Readability4J/issues/1]. + * Therefore no encoding is applied as intended in the js library, + * @see contentWithUtf8Encoding + * @see The github issue. */ val content: String? - get() = articleContent?.html() // TODO: but this removes paging information (pages in top node

) + get() = articleContent?.outerHtml() + + var siteName:String? = null + + var publishedTime:String? = null /** * [content] returns a <div> element. * @@ -38,26 +56,29 @@ open class Article( * So this method wraps [content] in <html><head><meta charset="utf-8"/></head><body><!-- * content--></body></html> so that UTF-8 encoding gets applied. * - * See [https://github.com/dankito/Readability4J/issues/1] for more info. + * @see The issue for more info. */ val contentWithUtf8Encoding: String? get() = getContentWithEncoding("utf-8") /** * Returns the content wrapped in an element with charset set to document's charset. Or if that is not set in UTF-8. - * See [contentWithUtf8Encoding] for more details. + * @see [contentWithUtf8Encoding] for more details. */ val contentWithDocumentsCharsetOrUtf8: String? get() = getContentWithEncoding(charset ?: "utf-8") + /** + * Content text (only text) + */ val textContent: String? get() = articleContent?.text() /** * Length of article, in characters */ - var length: Int = -1 - get() = textContent?.length ?: -1 + val length: Int + get() = textContent?.length ?: -1 /** * Article description, or short excerpt from content @@ -77,6 +98,7 @@ open class Article( /** * Article's charset */ + @Deprecated("Right now all sites uses utf-8", level = DeprecationLevel.WARNING) var charset: String? = null @@ -96,9 +118,8 @@ open class Article( content?.let { content -> return "\n \n \n \n \n " + "$content\n \n" - } - - return null + }?: return null } -} \ No newline at end of file + +} diff --git a/src/main/kotlin/net/dankito/readability4j/Readability4J.kt b/src/main/kotlin/net/dankito/readability4j/Readability4J.kt index 79b0e84..d4eb247 100644 --- a/src/main/kotlin/net/dankito/readability4j/Readability4J.kt +++ b/src/main/kotlin/net/dankito/readability4j/Readability4J.kt @@ -6,69 +6,42 @@ import net.dankito.readability4j.processor.ArticleGrabber import net.dankito.readability4j.processor.MetadataParser import net.dankito.readability4j.processor.Postprocessor import net.dankito.readability4j.processor.Preprocessor -import net.dankito.readability4j.util.RegExUtil import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import org.slf4j.Logger import org.slf4j.LoggerFactory - - -open class Readability4J { - - companion object { - private val log = LoggerFactory.getLogger(Readability4J::class.java) - } - - - protected val uri: String - - protected val document: Document - - protected val options: ReadabilityOptions - - protected val regEx: RegExUtil - - protected val preprocessor: Preprocessor - - protected val metadataParser: MetadataParser - - protected val articleGrabber: ArticleGrabber - - protected val postprocessor: Postprocessor - - - // TODO: add IDependencyResolver interface and @JvmOverloads - - // for Java interoperability - /** - * Calls Readability(String, String, ReadabilityOptions) with default ReadabilityOptions - */ - constructor(uri: String, html: String) : this(uri, html, ReadabilityOptions()) - - constructor(uri: String, html: String, options: ReadabilityOptions = ReadabilityOptions(), regExUtil: RegExUtil = RegExUtil(), - preprocessor: Preprocessor = Preprocessor(regExUtil), metadataParser: MetadataParser = MetadataParser(regExUtil), - articleGrabber: ArticleGrabber = ArticleGrabber(options, regExUtil), postprocessor: Postprocessor = Postprocessor()) - : this(uri, Jsoup.parse(html, uri), options, regExUtil, preprocessor, metadataParser, articleGrabber, postprocessor) - - // for Java interoperability - /** - * Calls Readability(String, Document, ReadabilityOptions) with default ReadabilityOptions - */ - constructor(uri: String, document: Document) : this(uri, document, ReadabilityOptions()) - - constructor(uri: String, document: Document, options: ReadabilityOptions = ReadabilityOptions(), regExUtil: RegExUtil = RegExUtil(), - preprocessor: Preprocessor = Preprocessor(regExUtil), metadataParser: MetadataParser = MetadataParser(regExUtil), - articleGrabber: ArticleGrabber = ArticleGrabber(options, regExUtil), postprocessor: Postprocessor = Postprocessor()) { - this.uri = uri - this.document = document - this.options = options - - this.regEx = regExUtil - this.preprocessor = preprocessor - this.metadataParser = metadataParser - this.articleGrabber = articleGrabber - this.postprocessor = postprocessor - } +import kotlin.system.measureNanoTime + + +open class Readability4J +/** + * Calls Readability4J with default params if no options provided, + * this constructor uses the uri for the postprocessing and Jsoup, + * as differ of js version keeps the url as you cant call in a html + * text documentUri as they call in the Postprocessor to process the URIs + * to make them absolute + * + * @param uri The uri (for Jsoup and for the Postprocessor) also can be empty string if + * you want to process manually after the article is served and don't waste that time + * @param html The page as string (this for Jsoup) + * @param options optional, if you don't provide it, will be all default options + * @see net.dankito.readability4j.model.ReadabilityOptions + * @see net.dankito.readability4j.processor.Postprocessor + */ +@Throws(ExceptionInInitializerError::class) +@JvmOverloads constructor( + val uri:String, + val html:String, + val options: ReadabilityOptions = ReadabilityOptions(), +) { + private val log: Logger = LoggerFactory.getLogger(Readability4J::class.java) + var metadataParser: MetadataParser = MetadataParser() + var preprocessor: Preprocessor = Preprocessor() + var articleGrabber: ArticleGrabber = ArticleGrabber(options) + var postprocessor: Postprocessor = Postprocessor() + // TODO: add IDependencyResolver interface + // ??????? /** @@ -82,31 +55,68 @@ open class Readability4J { * 4. Replace the current DOM tree with the new one. * 5. Read peacefully. * + * @return The actual article if the article exists in the html, + * else an empty Article with null content + * @throws RuntimeException if too many elements to parse (As you put in options) + * @see net.dankito.readability4j.Article + * */ + @Throws(RuntimeException::class) open fun parse(): Article { + + val document: Document + + log.info("Time parsing Document:{}",measureNanoTime { + document= Jsoup.parse(html,uri) + }) + // Avoid parsing too large documents, as per configuration option if (options.maxElemsToParse > 0) { - val numTags = document.getElementsByTag("*").size + val numTags = document.count() if(numTags > options.maxElemsToParse) { - throw Exception("Aborting parsing document; $numTags elements found, but ReadabilityOption.maxElemsToParse is set to ${options.maxElemsToParse}") + throw RuntimeException("Aborting parsing document; $numTags elements found, but ReadabilityOption.maxElemsToParse is set to ${options.maxElemsToParse}") } } - val article = Article(uri) + log.info("Time unwraping noscripts :{}",measureNanoTime { + preprocessor.unwrapNoscriptImages(document) + }) - preprocessor.prepareDocument(document) + var jsonLDMetadata:ArticleMetadata?=null + if (!options.disableJSONLD){ + log.info("Time Processing Json-LD :{}",measureNanoTime { + jsonLDMetadata=metadataParser.getJSONLD(document) + }) + } - val metadata = metadataParser.getArticleMetadata(document) + // this one also remove the scripts + log.info("Time Pre-Processing Document :{}",measureNanoTime { + preprocessor.prepareDocument(document) + }) + + val metadata: ArticleMetadata + log.info("Time Parsing Metadata :{}",measureNanoTime { + metadata = metadataParser.getArticleMetadata(document,jsonLDMetadata) + }) + + val articleContent: Element? + log.info("Time Grabbing Article :{}",measureNanoTime { + articleContent = articleGrabber.grabArticle(document, metadata) + }) + + val article = Article() + if (articleContent==null){ + return article.also { setArticleMetadata(article,metadata,null) } + // send a empty result, as nothing are found here + } - val articleContent = articleGrabber.grabArticle(document, metadata) log.debug("Grabbed: {}", articleContent) - articleContent?.let { // TODO: or return null if grabbing didn't work? - postprocessor.postProcessContent(document, articleContent, uri, options.additionalClassesToPreserve) - - article.articleContent = articleContent - } - + log.info("Time Post-Processing Document :{}",measureNanoTime { + //this is removing things af + postprocessor.postProcessContent( articleContent, document.baseUri(), uri, options ) + }) + article.articleContent = articleContent setArticleMetadata(article, metadata, articleContent) return article @@ -116,17 +126,21 @@ open class Readability4J { // If we haven't found an excerpt in the article's metadata, use the article's // first paragraph as the excerpt. This is used for displaying a preview of // the article's content. + if(metadata.excerpt.isNullOrBlank()) { articleContent?.getElementsByTag("p")?.first()?.let { firstParagraph -> - metadata.excerpt = firstParagraph.text().trim() + metadata.excerpt = firstParagraph.wholeText().trim() } } article.title = metadata.title article.byline = if(metadata.byline.isNullOrBlank()) articleGrabber.articleByline else metadata.byline + articleGrabber.articleLang?.let { article.lang= it} article.dir = articleGrabber.articleDir article.excerpt = metadata.excerpt - article.charset = metadata.charset + article.siteName = metadata.siteName + article.publishedTime = metadata.publishedTime + //article.charset = metadata.charset // this doesn't exist anymore in js } -} \ No newline at end of file +} diff --git a/src/main/kotlin/net/dankito/readability4j/extended/Readability4JExtended.kt b/src/main/kotlin/net/dankito/readability4j/extended/Readability4JExtended.kt index 8d1c804..4d0057f 100644 --- a/src/main/kotlin/net/dankito/readability4j/extended/Readability4JExtended.kt +++ b/src/main/kotlin/net/dankito/readability4j/extended/Readability4JExtended.kt @@ -3,12 +3,10 @@ package net.dankito.readability4j.extended import net.dankito.readability4j.Readability4J import net.dankito.readability4j.extended.processor.ArticleGrabberExtended import net.dankito.readability4j.extended.processor.PostprocessorExtended -import net.dankito.readability4j.extended.util.RegExUtilExtended +import net.dankito.readability4j.extended.util.BaseRegexUtilExtended import net.dankito.readability4j.model.ReadabilityOptions import net.dankito.readability4j.processor.MetadataParser import net.dankito.readability4j.processor.Preprocessor -import org.jsoup.Jsoup -import org.jsoup.nodes.Document open class Readability4JExtended : Readability4J { @@ -17,22 +15,21 @@ open class Readability4JExtended : Readability4J { /** * Calls Readability(String, String, ReadabilityOptions) with default ReadabilityOptions */ - constructor(uri: String, html: String) : this(uri, html, ReadabilityOptions()) - constructor(uri: String, html: String, options: ReadabilityOptions = ReadabilityOptions(), regExUtil: RegExUtilExtended = RegExUtilExtended(), - preprocessor: Preprocessor = Preprocessor(regExUtil), metadataParser: MetadataParser = MetadataParser(regExUtil), - articleGrabber: ArticleGrabberExtended = ArticleGrabberExtended(options, regExUtil), postprocessor: PostprocessorExtended = PostprocessorExtended()) - : this(uri, Jsoup.parse(html, uri), options, regExUtil, preprocessor, metadataParser, articleGrabber, postprocessor) - - // for Java interoperability - /** - * Calls Readability(String, Document, ReadabilityOptions) with default ReadabilityOptions - */ - constructor(uri: String, document: Document) : this(uri, document, ReadabilityOptions()) - - constructor(uri: String, document: Document, options: ReadabilityOptions = ReadabilityOptions(), regExUtil: RegExUtilExtended = RegExUtilExtended(), - preprocessor: Preprocessor = Preprocessor(regExUtil), metadataParser: MetadataParser = MetadataParser(regExUtil), - articleGrabber: ArticleGrabberExtended = ArticleGrabberExtended(options, regExUtil), postprocessor: PostprocessorExtended = PostprocessorExtended()) - : super(uri, document, options, regExUtil, preprocessor, metadataParser, articleGrabber, postprocessor) - -} \ No newline at end of file + @JvmOverloads + constructor(uri: String, + html: String, + options: ReadabilityOptions = ReadabilityOptions(), + regExUtil: BaseRegexUtilExtended = BaseRegexUtilExtended(), + preprocessor: Preprocessor = Preprocessor(regExUtil), + metadataParser: MetadataParser = MetadataParser(regExUtil), + articleGrabber: ArticleGrabberExtended = ArticleGrabberExtended(options, regExUtil), + postprocessor: PostprocessorExtended = PostprocessorExtended()) + : super(uri,html,options){ + this.articleGrabber=articleGrabber + this.preprocessor=preprocessor + this.metadataParser=metadataParser + this.postprocessor=postprocessor + } + +} diff --git a/src/main/kotlin/net/dankito/readability4j/extended/processor/ArticleGrabberExtended.kt b/src/main/kotlin/net/dankito/readability4j/extended/processor/ArticleGrabberExtended.kt index 1592e8b..b224f41 100644 --- a/src/main/kotlin/net/dankito/readability4j/extended/processor/ArticleGrabberExtended.kt +++ b/src/main/kotlin/net/dankito/readability4j/extended/processor/ArticleGrabberExtended.kt @@ -1,14 +1,15 @@ package net.dankito.readability4j.extended.processor -import net.dankito.readability4j.extended.util.RegExUtilExtended +import net.dankito.readability4j.extended.util.BaseRegexUtilExtended import net.dankito.readability4j.model.ReadabilityOptions import net.dankito.readability4j.processor.ArticleGrabber -import org.jsoup.nodes.Element +open class ArticleGrabberExtended @JvmOverloads constructor(options: ReadabilityOptions, protected val regExExtended: BaseRegexUtilExtended) : ArticleGrabber(options, regExExtended) { -open class ArticleGrabberExtended(options: ReadabilityOptions, protected val regExExtended: RegExUtilExtended) : ArticleGrabber(options, regExExtended) { + /* + todo do better implementation because - override fun shouldKeepSibling(sibling: Element): Boolean { + override fun shouldKeepSibling(sibling: Element): Boolean { return super.shouldKeepSibling(sibling) || containsImageToKeep(sibling) } @@ -17,7 +18,7 @@ open class ArticleGrabberExtended(options: ReadabilityOptions, protected val reg if(images.size > 0) { if(isImageElementToKeep(element)) { images.forEach { image -> - if(isImageElementToKeep(image) == false) { + if(!isImageElementToKeep(image)) { return false } } @@ -33,6 +34,6 @@ open class ArticleGrabberExtended(options: ReadabilityOptions, protected val reg val matchString = element.id() + " " + element.className() return regExExtended.keepImage(matchString) - } + }*/ -} \ No newline at end of file +} diff --git a/src/main/kotlin/net/dankito/readability4j/extended/processor/PostprocessorExtended.kt b/src/main/kotlin/net/dankito/readability4j/extended/processor/PostprocessorExtended.kt index 495695d..78dd946 100644 --- a/src/main/kotlin/net/dankito/readability4j/extended/processor/PostprocessorExtended.kt +++ b/src/main/kotlin/net/dankito/readability4j/extended/processor/PostprocessorExtended.kt @@ -1,33 +1,51 @@ package net.dankito.readability4j.extended.processor +import net.dankito.readability4j.model.ReadabilityOptions import net.dankito.readability4j.processor.Postprocessor import org.jsoup.nodes.Attributes -import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Tag open class PostprocessorExtended : Postprocessor() { - override fun postProcessContent(originalDocument: Document, articleContent: Element, articleUri: String, additionalClassesToPreserve: Collection) { + override fun postProcessContent( + articleContent: Element, + baseUri: String, + documentUri: String, + options: ReadabilityOptions + ) { // call these methods before super.postProcessContent() so that afterwards relative urls are made absolute makeLazyLoadingUrlsEagerLoading(articleContent) fixAmpImageUris(articleContent) - super.postProcessContent(originalDocument, articleContent, articleUri, additionalClassesToPreserve) + super.postProcessContent(articleContent, baseUri, documentUri, options) } protected open fun makeLazyLoadingUrlsEagerLoading(articleContent: Element) { articleContent.select("img").forEach { imgElement -> - makeLazyLoadingUrlEagerLoading(imgElement, "src", - listOf("data-src", "data-original", "data-actualsrc", "data-lazy-src", "data-delayed-url", - "data-li-src", "data-pagespeed-lazy-src")) + makeLazyLoadingUrlEagerLoading( + imgElement, "src", + listOf( + "data-src", + "data-original", + "data-actualsrc", + "data-lazy-src", + "data-delayed-url", + "data-li-src", + "data-pagespeed-lazy-src" + ) + ) } } - protected open fun makeLazyLoadingUrlEagerLoading(element: Element, attributeToSet: String, lazyLoadingAttributes: List) { + private fun makeLazyLoadingUrlEagerLoading( + element: Element, + attributeToSet: String, + lazyLoadingAttributes: List + ) { lazyLoadingAttributes.forEach { lazyLoadingAttributeName -> val value = element.attr(lazyLoadingAttributeName) @@ -39,7 +57,7 @@ open class PostprocessorExtended : Postprocessor() { } } - protected open fun fixAmpImageUris(element: Element) { + private fun fixAmpImageUris(element: Element) { element.getElementsByTag("amp-img").forEach { amp_img -> if (amp_img.childNodeSize() == 0) { @@ -53,9 +71,8 @@ open class PostprocessorExtended : Postprocessor() { } } - - override fun fixRelativeUris(originalDocument: Document, element: Element, scheme: String, prePath: String, - pathBase: String) { + //now the default implementation do that but just o + /*override fun fixRelativeUris(element: Element, pathBase: String, documentUri: String) { val baseUrl = originalDocument.head().select("base").first()?.attr("href") @@ -65,6 +82,5 @@ open class PostprocessorExtended : Postprocessor() { else { super.fixRelativeUris(originalDocument, element, scheme, prePath, pathBase) } - } - -} \ No newline at end of file + }*/ +} diff --git a/src/main/kotlin/net/dankito/readability4j/extended/util/RegExUtilExtended.kt b/src/main/kotlin/net/dankito/readability4j/extended/util/BaseRegexUtilExtended.kt similarity index 88% rename from src/main/kotlin/net/dankito/readability4j/extended/util/RegExUtilExtended.kt rename to src/main/kotlin/net/dankito/readability4j/extended/util/BaseRegexUtilExtended.kt index ac85bd8..23cf6d2 100644 --- a/src/main/kotlin/net/dankito/readability4j/extended/util/RegExUtilExtended.kt +++ b/src/main/kotlin/net/dankito/readability4j/extended/util/BaseRegexUtilExtended.kt @@ -1,10 +1,10 @@ package net.dankito.readability4j.extended.util -import net.dankito.readability4j.util.RegExUtil +import net.dankito.readability4j.util.BaseRegexUtil import java.util.regex.Pattern -open class RegExUtilExtended : RegExUtil { +open class BaseRegexUtilExtended : BaseRegexUtil { companion object { const val RemoveImageDefaultPattern = "author|avatar|thumbnail" // CHANGE: this is not in Mozilla's Readability @@ -15,7 +15,7 @@ open class RegExUtilExtended : RegExUtil { protected val removeImage: Pattern - + @JvmOverloads constructor(unlikelyCandidatesPattern: String = UnlikelyCandidatesDefaultPattern, okMaybeItsACandidatePattern: String = OkMaybeItsACandidateDefaultPattern, positivePattern: String = PositiveDefaultPattern, negativePattern: String = NegativeDefaultPattern + NegativeDefaultPatternExtended, extraneousPattern: String = ExtraneousDefaultPattern, bylinePattern: String = BylineDefaultPattern, @@ -30,11 +30,11 @@ open class RegExUtilExtended : RegExUtil { open fun keepImage(matchString: String): Boolean { // CHANGE: this is not in Mozilla's Readability - if((isNegative(matchString) && isPositive(matchString) == false) || removeImage.matcher(matchString).find()) { + if((isNegative(matchString) && !isPositive(matchString)) || removeImage.matcher(matchString).find()) { return false } return true } -} \ No newline at end of file +} diff --git a/src/main/kotlin/net/dankito/readability4j/model/ArticleGrabberOptions.kt b/src/main/kotlin/net/dankito/readability4j/model/ArticleGrabberOptions.kt index e431e05..52752e9 100644 --- a/src/main/kotlin/net/dankito/readability4j/model/ArticleGrabberOptions.kt +++ b/src/main/kotlin/net/dankito/readability4j/model/ArticleGrabberOptions.kt @@ -1,6 +1,12 @@ package net.dankito.readability4j.model - +/** + * This class represents the flags + * FLAG_STRIP_UNLIKELYS, + * FLAG_WEIGHT_CLASSES, + * FLAG_CLEAN_CONDITIONALLY + * on Readability.js + * */ open class ArticleGrabberOptions(var stripUnlikelyCandidates: Boolean = true, var weightClasses: Boolean = true, var cleanConditionally: Boolean = true) diff --git a/src/main/kotlin/net/dankito/readability4j/model/ArticleMetadata.kt b/src/main/kotlin/net/dankito/readability4j/model/ArticleMetadata.kt index 4731c33..c67069b 100644 --- a/src/main/kotlin/net/dankito/readability4j/model/ArticleMetadata.kt +++ b/src/main/kotlin/net/dankito/readability4j/model/ArticleMetadata.kt @@ -1,4 +1,48 @@ package net.dankito.readability4j.model -open class ArticleMetadata(var title: String? = null, var byline: String? = null, var excerpt: String? = null, var dir: String? = null, var charset: String? = null) \ No newline at end of file + +open class ArticleMetadata() //As the class itself its always called without any args removed them +{ + + /** + * Just for retrocompatibility + * + * */ + //but saved old constructor + constructor(title: String?=null, + byline: String?=null, + excerpt: String?=null, + dir: String?=null, + charset: String?=null) : this() { + this.title=title + this.byline=byline + this.excerpt=excerpt + this.dir=dir + this.charset=charset + } + + var title: String? = null + var byline: String? = null + var excerpt: String? = null + var siteName:String? = null + var publishedTime:String? = null + + //this is text direction that in + @Deprecated("This is always gotten from the ArticleGrabber Object," + + "don't use this one except for testing") + var dir :String?=null + @Deprecated("This is always utf-8 right now") + var charset: String? = "utf-8" + + override fun toString() = buildString { + arrayOf(title).joinToString() + } + + //JSONLDCompatibility + var datePublished:String? get(){ + return publishedTime + } set(value){ + publishedTime=value + } +} diff --git a/src/main/kotlin/net/dankito/readability4j/model/ReadabilityObject.kt b/src/main/kotlin/net/dankito/readability4j/model/ReadabilityObject.kt index 18ae24b..3b2d759 100644 --- a/src/main/kotlin/net/dankito/readability4j/model/ReadabilityObject.kt +++ b/src/main/kotlin/net/dankito/readability4j/model/ReadabilityObject.kt @@ -1,4 +1,6 @@ package net.dankito.readability4j.model - -open class ReadabilityObject(var contentScore: Double) \ No newline at end of file +/** + * This class is a dummy one for maintaining the porting easier as copy and paste + * */ +open class ReadabilityObject(var contentScore: Double) diff --git a/src/main/kotlin/net/dankito/readability4j/model/ReadabilityOptions.kt b/src/main/kotlin/net/dankito/readability4j/model/ReadabilityOptions.kt index e3b3e55..1d3ba40 100644 --- a/src/main/kotlin/net/dankito/readability4j/model/ReadabilityOptions.kt +++ b/src/main/kotlin/net/dankito/readability4j/model/ReadabilityOptions.kt @@ -1,10 +1,27 @@ package net.dankito.readability4j.model +import net.dankito.readability4j.util.BaseRegexUtil -open class ReadabilityOptions(val maxElemsToParse: Int = DEFAULT_MAX_ELEMS_TO_PARSE, + +open class ReadabilityOptions +@JvmOverloads +constructor(val maxElemsToParse: Int = DEFAULT_MAX_ELEMS_TO_PARSE, val nbTopCandidates: Int = DEFAULT_N_TOP_CANDIDATES, - val wordThreshold: Int = DEFAULT_WORD_THRESHOLD, - val additionalClassesToPreserve: Collection = ArrayList()) { + val charThreshold: Int = DEFAULT_CHAR_THRESHOLD, + //changed to set as readability as you shouldn't have duplicates here + val additionalClassesToPreserve: Set = setOf(), + val allowedVideoRegex: Regex=Regex(BaseRegexUtil.VideosDefaultPattern), + val linkDensityModifier: Double=0.0, + val disableJSONLD:Boolean = false , + val keepClasses: Boolean = false , + val debug:Boolean = false) { + + @Deprecated("", + replaceWith = ReplaceWith("charThreshold"), + level = DeprecationLevel.WARNING) + val wordThreshold:Int get() { + return charThreshold + } companion object { // Max number of nodes supported by this parser. Default: 0 (no limit) @@ -15,7 +32,12 @@ open class ReadabilityOptions(val maxElemsToParse: Int = DEFAULT_MAX_ELEMS_TO_PA const val DEFAULT_N_TOP_CANDIDATES = 5 // The default number of words an article must have in order to return a result + const val DEFAULT_CHAR_THRESHOLD = 500 + + @Deprecated("Changed to DEFAULT_CHAR_THRESHOLD", + replaceWith = ReplaceWith("DEFAULT_CHAR_THRESHOLD"), + level = DeprecationLevel.WARNING) const val DEFAULT_WORD_THRESHOLD = 500 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt index e4b4fd7..71ed912 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/ArticleGrabber.kt @@ -4,36 +4,44 @@ import net.dankito.readability4j.model.ArticleGrabberOptions import net.dankito.readability4j.model.ArticleMetadata import net.dankito.readability4j.model.ReadabilityObject import net.dankito.readability4j.model.ReadabilityOptions -import net.dankito.readability4j.util.RegExUtil +import net.dankito.readability4j.util.BaseRegexUtil +import net.dankito.readability4j.util.log +import net.dankito.readability4j.util.logDebug import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import org.jsoup.nodes.TextNode -import org.jsoup.select.Elements import org.slf4j.LoggerFactory -import java.util.* -import kotlin.collections.ArrayList -import kotlin.collections.HashMap +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min -open class ArticleGrabber(protected val options: ReadabilityOptions, protected val regEx: RegExUtil = RegExUtil()) : ProcessorBase() { +open class ArticleGrabber(options: ReadabilityOptions, override val regex: BaseRegexUtil = BaseRegexUtil()) : ProcessorBase() { companion object { // Element tags to score by default. val DEFAULT_TAGS_TO_SCORE = listOf("section", "h2", "h3", "h4", "h5", "h6", "p", "td", "pre") - val DIV_TO_P_ELEMS = listOf("a", "blockquote", "dl", "div", "img", "ol", "p", "pre", "table", "ul", "select") - - val ALTER_TO_DIV_EXCEPTIONS = listOf("div", "article", "section", "p") - val PRESENTATIONAL_ATTRIBUTES = listOf("align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", "vspace") + val DIV_TO_P_ELEMS = listOf("blockquote", "dl", "div", "img", "ol", "p", "pre", "table", "ul") + val UNLIKELY_ROLES = listOf("menu", "menubar", "complementary", "navigation", "alert", "alertdialog", "dialog") + val ALTER_TO_DIV_EXCEPTIONS = listOf("div", "article", "section", "p", "ol", "ul") + val PRESENTATIONAL_ATTRIBUTES = listOf("align", "background", "bgcolor", "border", + "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", + "vspace") val DEPRECATED_SIZE_ATTRIBUTE_ELEMS = listOf("table", "th", "td", "hr", "pre") - val EMBEDDED_NODES = listOf("object", "embed", "iframe") - val DATA_TABLE_DESCENDANTS = listOf("col", "colgroup", "tfoot", "thead", "th") - - + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + // "CANVAS", "IFRAME", "SVG", "VIDEO", + val PHRASING_ELEMS = listOf("abbr", "audio", "b", "bdo", "br", "button", "cite", "code", "data", "datalist", "dfn", "em", "embed", "i", "img", "input", "kbd", "label", "mark", "math", "meter", "noscript", "object", "output", "progress", "q", "ruby", "samp", "script", "select", "small", "span", "strong", "sub", "sup", "textarea", "time", "var", "wbr") private val log = LoggerFactory.getLogger(ArticleGrabber::class.java) } + var articleLang: String?= null + protected set + + var articleTitle: String? = null + private set + var articleByline: String? = null protected set @@ -41,33 +49,41 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v protected set - protected val nbTopCandidates = options.nbTopCandidates - protected val wordThreshold = options.wordThreshold + private val nbTopCandidates = options.nbTopCandidates + private val charThreshold = options.charThreshold - protected val readabilityObjects = HashMap() + private val readabilityObjects = HashMap() - protected val readabilityDataTable = HashMap() + private val readabilityDataTable = HashMap() + //changed to global because this class is always reinstated + private val options: ArticleGrabberOptions = ArticleGrabberOptions() + //changed to global because inside while is always reinstated and has no sense + private val attempts = arrayListOf>() - open fun grabArticle(doc: Document, metadata: ArticleMetadata, options: ArticleGrabberOptions = ArticleGrabberOptions(), pageElement: Element? = null): Element? { - log.debug("**** grabArticle ****") + open fun grabArticle(doc: Document, metadata: ArticleMetadata, pageElement: Element? = null): Element? { + log.info("**** grabArticle ****") val isPaging = pageElement != null val page = pageElement ?: doc.body() + articleTitle=metadata.title + articleByline=metadata.byline + // We can't grab an article if we don't have a page! if(page == null) { - log.debug("No body found in document. Abort.") + log.info("No body found in document. Abort.") return null } - val pageCacheHtml = doc.html() + val pageCacheHtml = page.html() while(true) { + log.info("Starting grabArticle loop") // First, node prepping. Trash nodes that look cruddy (like ones with the // class name "comment", etc), and turn divs into P tags where they have been // used inappropriately (as in, where they contain no other block level elements.) - val elementsToScore = prepareNodes(doc, options) + val elementsToScore = prepareNodes(doc) /** * Loop through all paragraphs, and assign a score to them based on how content-y they look. @@ -75,11 +91,12 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v * * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. **/ - val candidates = scoreElements(elementsToScore, options) + //cnn testcase problem starts here + val candidates = scoreElements(elementsToScore) // After we've calculated scores, loop through all of the possible // candidate nodes we found and find the one with the highest score. - val topCandidateResult = getTopCandidate(page, candidates, options) + val topCandidateResult = getTopCandidate(page, candidates) val topCandidate = topCandidateResult.first val neededToCreateTopCandidate= topCandidateResult.second @@ -89,61 +106,55 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v var articleContent = createArticleContent(doc, topCandidate, isPaging) - log.debug("Article content pre-prep: {}", articleContent.html()) + log.info("Article content pre-prep: {}", articleContent.html().logDebug()) // So we have all of the content that we need. Now we clean it up for presentation. - prepArticle(articleContent, options, metadata) - log.debug("Article content post-prep: {}", articleContent.html()) + // ok all the bugs left are here or that looks like + prepArticle(articleContent) + log.info("Article content post-prep: {}", articleContent.html().logDebug()) if(neededToCreateTopCandidate) { // We already created a fake div thing, and there wouldn't have been any siblings left // for the previous loop, so there's no point trying to create a new div, and then // move all the children over. Just assign IDs and class names here. No need to append // because that already happened anyway. - topCandidate.attr("id", "readability-page-1") - topCandidate.addClass("page") + topCandidate.id( "readability-page-1") + topCandidate.classNames(setOf("page")) } else { val div = doc.createElement("div") - div.attr("id", "readability-page-1") - div.addClass("page") + div.id("readability-page-1") + div.classNames(setOf("page")) - ArrayList(articleContent.childNodes()).forEach { child -> - child.remove() - div.appendChild(child) - } + div.appendChildren(articleContent.childNodes()) articleContent.appendChild(div) } - log.debug("Article content after paging: {}", articleContent.html()) + log.info("Article content after paging: {}", articleContent.html().logDebug()) var parseSuccessful = true - val attempts = ArrayList>() // Now that we've gone through the full algorithm, check to see if // we got any meaningful content. If we didn't, we may need to re-run // grabArticle with different flags set. This gives us a higher likelihood of // finding the content, and the sieve approach gives us a higher likelihood of // finding the -right- content. - val textLength = getInnerText(articleContent, regEx, true).length - if(textLength < this.wordThreshold) { + val textLength = getInnerText(articleContent, true).length + if(textLength < this.charThreshold) { parseSuccessful = false page.html(pageCacheHtml) + attempts.add(Pair(articleContent, textLength)) if(options.stripUnlikelyCandidates) { options.stripUnlikelyCandidates = false - attempts.add(Pair(articleContent, textLength)) } else if(options.weightClasses) { options.weightClasses = false - attempts.add(Pair(articleContent, textLength)) } else if(options.cleanConditionally) { options.cleanConditionally = false - attempts.add(Pair(articleContent, textLength)) } else { - attempts.add(Pair(articleContent, textLength)) // No luck after removing flags, just return the longest text we found during the different loops attempts.sortBy { it.second } @@ -169,50 +180,137 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v /* First step: prepare nodes */ - protected open fun prepareNodes(doc: Document, options: ArticleGrabberOptions): List { + private fun prepareNodes(doc: Document): List { val elementsToScore = ArrayList() var node: Element? = doc + var shouldRemoveTitleHeader = true while(node != null) { + if (node.tagName() == "html"){ + if (node.hasAttr("lang")) { + articleLang = node.attr("lang") + } + } + val matchString = node.className() + " " + node.id() - // Check to see if this node is a byline, and remove it if it is. - if(checkByline(node, matchString)) { - node = removeAndGetNext(node, "byline") + // Check if node is visible or no (who knows if you don't have the full page) + if(!isProbablyVisible(node)) { + log.info("Removing hidden node {}", matchString) + node = removeAndGetNext(node) + continue + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if ( + node.attr("aria-modal") == "true" && + node.attr("role") == "dialog" + ) { + node = removeAndGetNext(node) + continue + } + + // If we don't have a byline yet check to see if this node is a byline; + // if it is store the byline and remove the node. + if(articleByline == null && isValidByline(node, matchString)) { + + // Find child node matching [itemprop="name"] and use that if it exists for a more accurate author name byline + val endOfSearchMarkerNode = getNextNode(node, true) + var itemPropNameNode:Element? = null + var next = getNextNode(node) + while (next!=null && next != endOfSearchMarkerNode) { + val itemprop = next.attr("itemprop") + if (itemprop.isNotEmpty() && itemprop.contains("name")) { + itemPropNameNode = next + break + } else { + next = getNextNode(next) + } + } + articleByline = (itemPropNameNode?:node).text().trim() + node = removeAndGetNext(node) + continue + } + + if ( shouldRemoveTitleHeader && headerDuplicatesTitle(node)) { + log.info( + "Removing header: {} {}", + node.text().trim(), + this.articleTitle?.trim() + ) + shouldRemoveTitleHeader = false + node = removeAndGetNext(node) continue } // Remove unlikely candidates if(options.stripUnlikelyCandidates) { - if(regEx.isUnlikelyCandidate(matchString) && - regEx.okMaybeItsACandidate(matchString) == false && - node.tagName() != "body" && - node.tagName() != "a") { - node = this.removeAndGetNext(node, "Removing unlikely candidate") + if(regex.isUnlikelyCandidate(matchString) && + !regex.okMaybeItsACandidate(matchString) && + !hasAncestorTag(node, "table") && + !hasAncestorTag(node, "code") && + node.tagName() !in listOf("body", "a")) { + log.info("Removing unlikely candidate - {}", matchString) + + node = this.removeAndGetNext(node) + continue + } + + if (node.attr("role") in UNLIKELY_ROLES){ + log.info( + "Removing content with role {}{}{}", + node.attr("role"), + " - ", + matchString + ) + node = removeAndGetNext(node) continue } } + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). - if((node.tagName() == "div" || node.tagName() == "section" || node.tagName() == "header" || - node.tagName() == "h1" || node.tagName() == "h2" || node.tagName() == "h3" || - node.tagName() == "h4" || node.tagName() == "h5" || node.tagName() == "h6") && - this.isElementWithoutContent(node)) { - node = this.removeAndGetNext(node, "node without content") + if((node.tagName() in + listOf("div","section","header","h1","h2","h3","h4","h5","h6")) + && this.isElementWithoutContent(node)) { + node = this.removeAndGetNext(node) continue } - if(DEFAULT_TAGS_TO_SCORE.contains(node.tagName())) { + if(node.tagName() in DEFAULT_TAGS_TO_SCORE) { elementsToScore.add(node) } // Turn all divs that don't have children block level elements into p's if(node.tagName() == "div") { + // Put phrasing content into paragraphs. + var p: Element?=null + var childNode = node.firstChild() + while (childNode!=null) { + val nextSibling = childNode.nextSibling() + if (isPhrasingContent(childNode)) { + if (p != null) { + p.appendChild(childNode) + } else if (!isWhitespace(childNode)) { + p=doc.createElement("p") + childNode.replaceWith(p!!) + p.appendChild(childNode) + } + } else if (p != null) { + while (p.lastChild()?.let { isWhitespace(it) } == true) { + p.lastChild()?.remove() + } + p = null + } + childNode = nextSibling + } + // Sites like http://mobile.slate.com encloses each paragraph with a DIV // element. DIVs with only a P element inside and no text content can be // safely converted into plain P elements to avoid confusing the scoring // algorithm with DIVs with are, in practice, paragraphs. - if(this.hasSinglePInsideElement(node)) { + if(hasSingleTagInsideElement(node,"p") && + getLinkDensity(node) < 0.25) { val newNode = node.child(0) node.replaceWith(newNode) node = newNode @@ -222,161 +320,141 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v setNodeTag(node, "p") elementsToScore.add(node) } - else { - // EXPERIMENTAL - node.childNodes().forEach { childNode -> - if(childNode is TextNode && childNode.text().trim().length > 0) { - val p = doc.createElement("p") - p.text(childNode.text()) - p.attr("style", "display: inline;") - p.addClass("readability-styled") - childNode.replaceWith(p) - } - } - } } - - node = if(node != null) this.getNextNode(node) else null + node = this.getNextNode(node) } return elementsToScore } - protected open fun checkByline(node: Element, matchString: String): Boolean { - if(this.articleByline != null) { + + private fun headerDuplicatesTitle(node: Element): Boolean { + if (articleTitle==null) + return false + if (!node.tagName().equals("H1",ignoreCase = true) && + !node.tagName().equals("H2",ignoreCase = true)) { return false } + val heading = node.text() + log.info("Evaluating similarity of header: {} {}", heading, articleTitle) + return this.textSimilarity(articleTitle!!, heading) > 0.75 + } - val rel = node.attr("rel") - if((rel == "author" || regEx.isByline(matchString)) && isValidByline(node.wholeText())) { - this.articleByline = node.text().trim() - return true - } - return false + private fun isProbablyVisible(node: Element): Boolean { + return(!node.hasAttr("style") || + !node.attr("style") + .contains(Regex("(display(\\s*)?:(\\s*)?none)|(visibility(\\s*)?:(\\s*)?hidden)"))) && + !node.hasAttr("hidden") && + (!node.hasAttr("aria-hidden") || + (!node.attr("aria-hidden").contains("true") && node.attr("aria-hidden").isNotBlank()) || + (node.className().isNotEmpty() && + node.className().contains("fallback-image"))) } + +// protected open fun checkAndSaveByline(node: Element, matchString: String): Boolean { +// +// return true +// } + /** * Check whether the input string could be a byline. * This verifies that the input is a string, and that the length * is less than 100 chars. */ - protected open fun isValidByline(text: String): Boolean { - val byline = text.trim() - - return (byline.isNotEmpty()) && (byline.length < 100) - } - + private fun isValidByline(node: Element, matchString: String): Boolean { + val rel = node.attr("rel") + val itemprop = node.attr("itemprop") + val bylineLength = node.wholeText().trim().length - protected open fun isElementWithoutContent(node: Element): Boolean { - return node.text().isBlank() && - (node.children().size == 0 || - node.children().size == node.getElementsByTag("br").size + node.getElementsByTag("hr").size) + return ((rel == "author" || itemprop.contains("author") + || regex.isByline(matchString)) && bylineLength in 1 until 100) } - /** - * Check if this node has only whitespace and a single P element - * Returns false if the DIV node contains non-empty text nodes - * or if it contains no P or more than 1 element. - */ - protected open fun hasSinglePInsideElement(element: Element): Boolean { - // There should be exactly 1 element child which is a P: - if(element.children().size != 1 || element.child(0).tagName() != "p") { - return false - } - - // And there should be no text nodes with real content - element.childNodes().forEach { node -> - if(node is TextNode && regEx.hasContent(node.text())) { - return false - } - } - - return true - } - /** * Determine whether element has any children block level elements. */ - protected open fun hasChildBlockElement(element: Element): Boolean { - element.children().forEach { node -> - if(DIV_TO_P_ELEMS.contains(node.tagName()) || hasChildBlockElement(node)) { - return true - } + private fun hasChildBlockElement(element: Element): Boolean { + return element.children().any { node -> + node.tagName() in DIV_TO_P_ELEMS || hasChildBlockElement(node) } - - return false } - protected open fun setNodeTag(node: Element, tagName: String) { + private fun setNodeTag(node: Element, tagName: String): Element { + log.info("setNodeTag {} {}", node.log(), tagName) node.tagName(tagName) + return node } - /* Second step: Score elements */ - protected open fun scoreElements(elementsToScore: List, options: ArticleGrabberOptions): List { + private fun scoreElements(elementsToScore: List): List { val candidates = ArrayList() - + val candidateLog = false elementsToScore.forEach { elementToScore -> if(elementToScore.parentNode() == null) { return@forEach } // If this paragraph is less than 25 characters, don't even count it. - val innerText = this.getInnerText(elementToScore, regEx) + val innerText = this.getInnerText(elementToScore) if(innerText.length < 25) { return@forEach } // Exclude nodes with no ancestor. - val ancestors = this.getNodeAncestors(elementToScore, 3) - if(ancestors.size == 0) { + val ancestors = this.getNodeAncestors(elementToScore, 5) + if(ancestors.isEmpty()) { return@forEach } - var contentScore = 0.0 + var contentScore:Double = 0.0 // Add a point for the paragraph itself as a base. contentScore += 1 // Add points for any commas within this paragraph. - contentScore += innerText.split(',').size + contentScore += regex.splitCommas(innerText).size // For every 100 characters in this paragraph, add another point. Up to 3 points. - contentScore += Math.min(Math.floor(innerText.length / 100.0), 3.0) + contentScore += min(floor(innerText.length / 100.0),3.0) // Initialize and score ancestors. - for(level in 0..ancestors.size - 1) { - val ancestor = ancestors[level] - if(ancestor.tagName().isNullOrBlank()) { // with Jsoup this should never be true as we're only handling Elements - return@forEach + for((level,ancestor) in ancestors.withIndex()) { + if(ancestor.tagName().isNullOrBlank() && ancestor.parentNode() !=null) { + break } - if(getReadabilityObject(ancestor) == null) { + if(ancestor is Document|| ancestor.normalName()=="html") { + break + } + + if (ancestor.id()=="storycontent" && !candidateLog){ + ancestors.withIndex().forEach { log.info("ancestor level {} {}", it.index, it.value.log()) } + } + + if(ancestor.readability == null) { + initializeNode(ancestor) candidates.add(ancestor) - initializeNode(ancestor, options) } // Node score divider: // - parent: 1 (no division) // - grandparent: 2 // - great grandparent+: ancestor level * 3 - val scoreDivider = - if(level == 0) - 1 - else if(level == 1) - 2 - else - level * 3 - - getReadabilityObject(ancestor)?.let { readability -> - readability.contentScore += contentScore / scoreDivider.toDouble() - } + val scoreDivider:Double = + when (level) { + 0 -> 1.0 + 1 -> 2.0 + else -> level * 3.0 + } + + ancestor.readability!!.contentScore += contentScore / scoreDivider } } @@ -387,9 +465,8 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v * Initialize a node with the readability object. Also checks the * className/id for special names to add to its score. */ - protected open fun initializeNode(node: Element, options: ArticleGrabberOptions): ReadabilityObject { + private fun initializeNode(node: Element) { val readability = ReadabilityObject(0.0) - readabilityObjects.put(node, readability) when(node.tagName()) { "div" -> @@ -420,17 +497,17 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v readability.contentScore -= 5 } - readability.contentScore += getClassWeight(node, options) + readability.contentScore += getClassWeight(node) - return readability + node.readability = readability } /** * Get an elements class/id weight. Uses regular expressions to tell if this * element looks good or bad. */ - protected open fun getClassWeight(e: Element, options: ArticleGrabberOptions): Int { - if(options.weightClasses == false) { + private fun getClassWeight(e: Element): Int { + if(!options.weightClasses) { return 0 } @@ -438,22 +515,22 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v // Look for a special classname if(e.className().isNotBlank()) { - if(regEx.isNegative(e.className())) { + if(regex.isNegative(e.className())) { weight -= 25 } - if(regEx.isPositive(e.className())) { + if(regex.isPositive(e.className())) { weight += 25 } } // Look for a special ID if(e.id().isNotBlank()) { - if(regEx.isNegative(e.id())) { + if(regex.isNegative(e.id())) { weight -= 25 } - if(regEx.isPositive(e.id())) { + if(regex.isPositive(e.id())) { weight += 25 } } @@ -461,18 +538,18 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v return weight } - protected open fun getNodeAncestors(node: Element, maxDepth: Int = 0): List { + private fun getNodeAncestors(node: Element, maxDepth: Int = 0): List { var i = 0 val ancestors = ArrayList() var next = node while(next.parent() != null) { - ancestors.add(next.parent()!!) - if(++i == maxDepth) { + val parentNode = next.parent()!! + ancestors.add(parentNode) + if((maxDepth>=1 && ++i == maxDepth)) { break } - - next = next.parent()!! + next = parentNode } return ancestors @@ -482,26 +559,28 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v /* Third step: Get top candidate */ - protected open fun getTopCandidate(page: Element, candidates: List, options: ArticleGrabberOptions): Pair { + private fun getTopCandidate(page: Element, candidates: List): Pair { val topCandidates = ArrayList() candidates.forEach { candidate -> - getReadabilityObject(candidate)?.let { readability -> + candidate.readability?.let { readability -> // Scale the final candidates score based on link density. Good content // should have a relatively small link density (5% or less) and be mostly // unaffected by this operation. - val candidateScore = readability.contentScore * (1 - this.getLinkDensity(candidate)) - readability.contentScore = candidateScore + val ld =this.getLinkDensity(candidate) +// log.info("Before ld score: {}",candidate.readability?.contentScore) + + val candidateScore = readability.contentScore * (1 - ld) + candidate.readability?.contentScore = candidateScore - log.debug("Candidate: {} with score {}", candidate, candidateScore) + log.info("Candidate:\",\"{}\",\"with score {}\"]", candidate.log(), candidateScore) - for(t in 0..nbTopCandidates - 1) { + for(t in 0.. t) topCandidates[t] else null - val topCandidateReadability = if(aTopCandidate != null) getReadabilityObject(aTopCandidate) else null + val topCandidateReadability = aTopCandidate?.readability if(aTopCandidate == null || (topCandidateReadability != null && candidateScore > topCandidateReadability.contentScore)) { topCandidates.add(t, candidate) - if(topCandidates.size > this.nbTopCandidates) { topCandidates.removeAt(nbTopCandidates) } @@ -521,15 +600,18 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v topCandidate = Element("div") // Move everything (not just elements, also text nodes etc.) into the container // so we even include text directly in the body: - ArrayList(page.childNodes()).forEach { child -> - log.debug("Moving child out: {}", child) - child.remove() - topCandidate?.appendChild(child) + while(page.firstChild()!=null){ +// if (child is Comment){ +// //javascript ignores it +// return@forEach +// } + log.info("Moving child out: {}", page.firstChild()?.log()) + page.firstChild()?.let { topCandidate.appendChild(it) } } page.appendChild(topCandidate) - this.initializeNode(topCandidate, options) + initializeNode(topCandidate) return Pair(topCandidate, true) } @@ -538,24 +620,34 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v // and whose scores are quite closed with current `topCandidate` node. val alternativeCandidateAncestors = ArrayList>() - getReadabilityObject(topCandidate)?.let { topCandidateReadability -> - topCandidates.filter { it != topCandidate }.forEach { otherTopCandidate -> - if(((getReadabilityObject(otherTopCandidate)?.contentScore ?: 0.0) / topCandidateReadability.contentScore) >= 0.75) { - alternativeCandidateAncestors.add(this.getNodeAncestors(otherTopCandidate)) + for (otherTopCandidate in topCandidates.filter { it != topCandidate }) { + topCandidate.readability?.let { topCandidateReadability -> + otherTopCandidate.readability?.let {otherTopCandidateReadability-> + if (((otherTopCandidateReadability.contentScore) / + topCandidateReadability.contentScore) >= 0.75 + ) { + alternativeCandidateAncestors.add( + this.getNodeAncestors( + otherTopCandidate + ) + ) + } } } } - val MINIMUM_TOPCANDIDATES = 3 + if(alternativeCandidateAncestors.size >= MINIMUM_TOPCANDIDATES) { parentOfTopCandidate = topCandidate.parent() - - while(parentOfTopCandidate != null && parentOfTopCandidate.tagName() !== "body") { + while(parentOfTopCandidate != null && parentOfTopCandidate.tagName() != "body") { var listsContainingThisAncestor = 0 var ancestorIndex = 0 - while(ancestorIndex < alternativeCandidateAncestors.size && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES) { - if(alternativeCandidateAncestors[ancestorIndex].contains(parentOfTopCandidate)) { + while(ancestorIndex < alternativeCandidateAncestors.size && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES) { + + if(alternativeCandidateAncestors[ancestorIndex]. + contains(parentOfTopCandidate)) { listsContainingThisAncestor++ } ancestorIndex++ @@ -569,9 +661,9 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v } } - topCandidate = topCandidate!! - if(getReadabilityObject(topCandidate) == null) { - this.initializeNode(topCandidate, options) + + if(topCandidate!!.readability == null) { + initializeNode(topCandidate) } // Because of our bonus system, parents of candidates might have scores @@ -582,12 +674,12 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v // below does some of that - but only if we've looked high enough up the DOM // tree. parentOfTopCandidate = topCandidate.parent() - var lastScore = getReadabilityObject(topCandidate)?.contentScore ?: 0.0 + var lastScore = topCandidate.readability?.contentScore!! // The scores shouldn't get too low. val scoreThreshold = lastScore / 3.0 while(parentOfTopCandidate != null && parentOfTopCandidate.tagName() != "body") { - val parentOfTopCandidateReadability = getReadabilityObject(parentOfTopCandidate) + val parentOfTopCandidateReadability = parentOfTopCandidate.readability if(parentOfTopCandidateReadability == null) { parentOfTopCandidate = parentOfTopCandidate.parent() continue @@ -609,16 +701,14 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v // If the top candidate is the only child, use parent instead. This will help sibling // joining logic when adjacent content is actually located in parent's sibling node. - topCandidate = topCandidate!! - parentOfTopCandidate = topCandidate.parent() + parentOfTopCandidate = topCandidate!!.parent() while(parentOfTopCandidate != null && parentOfTopCandidate.tagName() != "body" && parentOfTopCandidate.children().size == 1) { topCandidate = parentOfTopCandidate parentOfTopCandidate = topCandidate.parent() } - topCandidate = topCandidate!! - if(getReadabilityObject(topCandidate) == null) { - this.initializeNode(topCandidate, options) + if(topCandidate.readability == null) { + initializeNode(topCandidate) } return Pair(topCandidate, false) @@ -629,17 +719,19 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v * Get the density of links as a percentage of the content * This is the amount of text that is inside a link divided by the total text in the node. */ - protected open fun getLinkDensity(element: Element): Double { - val textLength = this.getInnerText(element, regEx).length + private fun getLinkDensity(element: Element): Double { + val textLength = this.getInnerText(element).length if(textLength == 0) { return 0.0 } - var linkLength = 0 + var linkLength = 0.0 // XXX implement _reduceNodeList? element.getElementsByTag("a").forEach { linkNode -> - linkLength += this.getInnerText(linkNode, regEx).length + val href = linkNode.attr("href") + val coefficient = if (href.isNotBlank() && regex.isHashUrl(href)) 0.3 else 1.0 + linkLength += this.getInnerText(linkNode).length.toDouble() * coefficient } return linkLength / textLength.toDouble() @@ -649,77 +741,89 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v /* Forth step: Create articleContent */ - protected open fun createArticleContent(doc: Document, topCandidate: Element, isPaging: Boolean): Element { + private fun createArticleContent(doc: Document, topCandidate: Element, isPaging: Boolean): Element { val articleContent = doc.createElement("div") if(isPaging) { articleContent.attr("id", "readability-content") } - val topCandidateReadability = getReadabilityObject(topCandidate) - if(topCandidateReadability == null) { - return articleContent - } + val topCandidateReadability = topCandidate.readability ?: return articleContent - val siblingScoreThreshold = Math.max(10.0, topCandidateReadability.contentScore * 0.2) + val siblingScoreThreshold = max(10.0, topCandidateReadability.contentScore * 0.2) // Keep potential top candidate's parent node to try to get text direction of it later. - val parentOfTopCandidate = topCandidate.parent() // parentOfTopCandidate may is null, see issue #12 - val siblings = parentOfTopCandidate?.children() ?: Elements() - - ArrayList(siblings).forEach { sibling -> // make a copy of children as the may get modified below -> we can get rid of s -= 1 sl -= 1 compared to original source - var append = false - - val siblingReadability = getReadabilityObject(sibling) - log.debug("Looking at sibling node: {} with score {}", sibling, siblingReadability?.contentScore ?: 0) - log.debug("Sibling has score {}", siblingReadability?.contentScore?.toString() ?: "Unknown") - - if(sibling == topCandidate) { - append = true - } - else { - var contentBonus = 0.0 - - // Give a bonus if sibling nodes and top candidates have the example same classname - if(sibling.className() == topCandidate.className() && topCandidate.className() !== "") - contentBonus += topCandidateReadability.contentScore * 0.2 - - if(siblingReadability != null && - ((siblingReadability.contentScore + contentBonus) >= siblingScoreThreshold)) { + // parentOfTopCandidate may is null, see issue #12 + topCandidate.parent()?.let {parentOfTopCandidate-> + val siblings = parentOfTopCandidate.children() + + ArrayList(siblings).forEach { sibling -> // make a copy of children as the may get modified below -> we can get rid of s -= 1 sl -= 1 compared to original source + // make a copy of children as the may get modified below -> we can get rid of s -= 1 sl -= 1 compared to original source + val siblingReadability = sibling.readability + var append = false + + log.info( + "Looking at sibling node: {} with score {}", + sibling.log(), + siblingReadability?.contentScore ?: "Unknown" + ) + + log.info( + "Sibling has score {}", + siblingReadability?.contentScore ?: "Unknown" + ) + + if (sibling == topCandidate) { append = true } - else if(shouldKeepSibling(sibling)) { - val linkDensity = this.getLinkDensity(sibling) - val nodeContent = this.getInnerText(sibling, regEx) - val nodeLength = nodeContent.length + else { + var contentBonus = 0.0 - if(nodeLength > 80 && linkDensity < 0.25) { - append = true + // Give a bonus if sibling nodes and top candidates have the example same classname + if (sibling.className() == topCandidate.className() && + topCandidate.className() != "") { + contentBonus += topCandidateReadability.contentScore * 0.2 } - else if(nodeLength < 80 && nodeLength > 0 && linkDensity == 0.0 && - nodeContent.contains("\\.( |$)".toRegex())) { + + if (siblingReadability != null && + ((siblingReadability.contentScore + contentBonus) >= + siblingScoreThreshold) + ) { append = true + } else if (shouldKeepSibling(sibling)) { + val linkDensity = this.getLinkDensity(sibling) + val nodeContent = this.getInnerText(sibling) + val nodeLength = nodeContent.length + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true + } else if ( + nodeLength in 1..79 && + linkDensity == 0.0 && + nodeContent.contains("\\.( |$)".toRegex()) + ) { + append = true + } } } - } - if(append) { - log.debug("Appending node: {}", sibling) + if (append) { + log.info("Appending node: {}", sibling.log()) - if(ALTER_TO_DIV_EXCEPTIONS.contains(sibling.tagName()) == false) { - // We have a node that isn't a common block level element, like a form or td tag. - // Turn it into a div so it doesn't get filtered out later by accident. - log.debug("Altering sibling: {} to div.", sibling) + if (sibling.tagName() !in ALTER_TO_DIV_EXCEPTIONS) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + log.info("Altering sibling: {} to div.", sibling.log()) - setNodeTag(sibling, "div") - } + setNodeTag(sibling, "div") + } - articleContent.appendChild(sibling) + articleContent.appendChild(sibling) + } } } - return articleContent } - protected open fun shouldKeepSibling(sibling: Element): Boolean { + open fun shouldKeepSibling(sibling: Element): Boolean { return sibling.tagName() == "p" } @@ -731,51 +835,35 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v * Prepare the article node for display. Clean out any inline styles, * iframes, forms, strip extraneous

tags, etc. */ - protected open fun prepArticle(articleContent: Element, options: ArticleGrabberOptions, metadata: ArticleMetadata) { - this.cleanStyles(articleContent) + private fun prepArticle(articleContent: Element) { + //removed metadata as it isn't used anymore + cleanStyles(articleContent) // Check for data tables before we continue, to avoid removing items in // those tables, which will often be isolated even though they're // visually linked to other content-ful elements (text, images, etc.). markDataTables(articleContent) + fixLazyImages(articleContent) + // Clean out junk from the article content - this.cleanConditionally(articleContent, "form", options) - this.cleanConditionally(articleContent, "fieldset", options) + this.cleanConditionally(articleContent, "form") + this.cleanConditionally(articleContent, "fieldset") this.clean(articleContent, "object") this.clean(articleContent, "embed") this.clean(articleContent, "footer") this.clean(articleContent, "link") + this.clean(articleContent, "aside") // Clean out elements have "share" in their id/class combinations from final top candidates, // which means we don't remove the top candidates even they have "share". - val shareRegex = "share".toRegex() - articleContent.children().forEach { topCandidate -> - cleanMatchedNodes(topCandidate, shareRegex) - } - // If there is only one h2 and its text content substantially equals article title, - // they are probably using it as a header and not a subheader, - // so remove it since we already extract the title separately. - val h2 = articleContent.getElementsByTag("h2") - if (h2.size == 1) { - metadata.title?.let { articleTitle -> - if(articleTitle.length > 0) { - val lengthSimilarRate = (h2[0].text().length - articleTitle.length) / articleTitle.length.toFloat() - if (Math.abs(lengthSimilarRate) < 0.5) { - val titlesMatch = - if(lengthSimilarRate > 0) { - h2[0].text().contains(articleTitle) - } - else { - articleTitle.contains(h2[0].text()) - } - - if(titlesMatch) { - this.clean(articleContent, "h2") - } - } - } + val shareElementThreshold = ReadabilityOptions.DEFAULT_CHAR_THRESHOLD + + articleContent.children().forEach { topCandidate -> + cleanMatchedNodes(topCandidate){ node, className-> + regex.isShareElement(className) && + node.text().length < shareElementThreshold } } @@ -784,171 +872,310 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v this.clean(articleContent, "textarea") this.clean(articleContent, "select") this.clean(articleContent, "button") - this.cleanHeaders(articleContent, options) + this.cleanHeaders(articleContent) // Do these last as the previous stuff may have removed junk // that will affect these - this.cleanConditionally(articleContent, "table", options) - this.cleanConditionally(articleContent, "ul", options) - this.cleanConditionally(articleContent, "div", options) + this.cleanConditionally(articleContent, "table") + this.cleanConditionally(articleContent, "ul") + this.cleanConditionally(articleContent, "div") + + // replace H1 with H2 as H1 should be only title that is displayed separately + this.replaceNodeTags(articleContent.getElementsByTag("h1"), "h2") // Remove extra paragraphs - removeNodes(articleContent, "p") { paragraph -> - val imgCount = paragraph.getElementsByTag("img").size - val embedCount = paragraph.getElementsByTag("embed").size - val objectCount = paragraph.getElementsByTag("object").size - // At this point, nasty iframes have been removed, only remain embedded video ones. - val iframeCount = paragraph.getElementsByTag("iframe").size - val totalCount = imgCount + embedCount + objectCount + iframeCount - - return@removeNodes totalCount == 0 && getInnerText(paragraph, normalizeSpaces = false).length == 0 + removeNodes(articleContent.getElementsByTag("p")) { paragraph -> + // At this point, nasty iframes have been removed; only embedded video + // ones remain. + val contentElementCount = paragraph.getAllNodesWithTag(arrayOf( + "img", + "embed", + "object", + "iframe" + )).size + + return@removeNodes contentElementCount == 0 && getInnerText( + paragraph, + normalizeSpaces = false + ).isEmpty() } articleContent.select("br").forEach { br -> - val next = nextElement(br.nextSibling(), regEx) - if(next != null && next.tagName() == "p") { + val next = nextNode(br.nextSibling()) + if(next != null && next is Element && next.tagName() == "p") { br.remove() } } + // Remove single-cell tables + articleContent.getElementsByTag("table").forEach { table -> + val tbody = if (this.hasSingleTagInsideElement(table, "tbody")) + table.firstElementChild() + else table + if (tbody?.let { this.hasSingleTagInsideElement(it, "tr") } == true) { + val row = tbody.firstElementChild() + if (row?.let { hasSingleTagInsideElement(it, "td") } == true) { + row.firstElementChild()?.let { cell -> + cell.tagName(if (cell.children().all { isPhrasingContent(it) }) "p" else "div") + table.replaceWith(cell) + } + } + } + } + } + + /* convert images and figures that have properties like data-src into images that can be loaded without JS */ + private fun fixLazyImages(root: Element) { + + root.getAllNodesWithTag(arrayOf("img","picture","figure")) + .forEach function@ { elem -> + + // In some sites (e.g. Kotaku), they put 1px square image as base64 data uri in the src attribute. + // So, here we check if the data uri is too short, just might as well remove it. + var attributes = elem.attributes().toList() + if (elem.attr("src").isNotBlank() && regex.isB64Data(elem.attr("src"))) { + // Make sure it's not SVG, because SVG can have a meaningful image in under 133 bytes. + val parts = regex.getB64Matches(elem.attr("src")) + val dataType = parts?.groups?.get(1)?.value + if ( dataType == "image/svg+xml") { + return@function + } + + // Make sure this element has other attributes which contains image. + // If it doesn't, then this src is important and shouldn't be removed. + var srcCouldBeRemoved = false + attributes.forEach { attr-> + if (!srcCouldBeRemoved && (attr.key != "src") && + "\\.(jpg|jpeg|png|webp)" + .toRegex(RegexOption.IGNORE_CASE) + .containsMatchIn(attr.value)) { + srcCouldBeRemoved = true + } + } + + // Here we assume if image is less than 100 bytes (or 133 after encoded to base64) + // it will be too small, therefore it might be placeholder image. + if (srcCouldBeRemoved) { + //if you get there this isn't possible to be null + val b64starts = parts?.groups?.get(0)?.value?.length!! + val b64length = elem.attr("src").length - b64starts + if (b64length < 133) { + elem.removeAttr("src") + } + } + } + + // also check for "null" to work around https://github.com/jsdom/jsdom/issues/2580 + // but this one only applies to js + if ( + (elem.attr("src").isNotBlank() || elem.attr("srcset").isNotBlank()) && + "lazy" !in elem.className().lowercase() + ) { + return@function + } + attributes=elem.attributes().toList() + attributes.forEach attrs@{ attr-> + if ( + attr.key == "src" || + attr.key == "srcset" || + attr.key == "alt" + ) { + return@attrs + } + var copyTo:String? = null + if ( "\\.(jpg|jpeg|png|webp)\\s+\\d".toRegex().containsMatchIn(attr.value)) { + copyTo = "srcset" + } else if ("^\\s*\\S+\\.(jpg|jpeg|png|webp)\\S*\\s*$".toRegex().containsMatchIn(attr.value)) { + copyTo = "src" + } + if (copyTo!=null) { + //if this is an img or picture, set the attribute directly + if (elem.tagName() == "img" || elem.tagName() == "picture") { + elem.attr(copyTo, attr.value) + } else if ( + elem.tagName() == "figure" && + (elem.getAllNodesWithTag(arrayOf("img","picture"))).isEmpty() + ) { + //if the item is a

that does not contain an image or picture, create one and place it inside the figure + //see the nytimes-3 testcase for an example + val img = Element("img") + img.attr(copyTo, attr.value) + elem.appendChild(img) + } + } + } + } } /** * Remove the style attribute on every e and under. * TODO: Test if getElementsByTagName(*) is faster. */ - protected open fun cleanStyles(e: Element) { + private fun cleanStyles(e: Element) { if(e.tagName() == "svg") { return } - if(e.className() !== "readability-styled") { - // Remove `style` and deprecated presentational attributes - PRESENTATIONAL_ATTRIBUTES.forEach { attributeName -> - e.removeAttr(attributeName) - } +// Not in Readability.js +// if(e.className() != "readability-styled") { + // Remove `style` and deprecated presentational attributes + PRESENTATIONAL_ATTRIBUTES.forEach { attributeName -> + e.removeAttr(attributeName) + } - if(DEPRECATED_SIZE_ATTRIBUTE_ELEMS.contains(e.tagName())) { - e.removeAttr("width") - e.removeAttr("height") - } + if(DEPRECATED_SIZE_ATTRIBUTE_ELEMS.contains(e.tagName())) { + e.removeAttr("width") + e.removeAttr("height") } +// } e.children().forEach { child -> cleanStyles(child) } } - protected open fun markDataTables(root: Element) { + /** + * Look for 'data' (as opposed to 'layout') tables, for which we use + * similar checks as + * https://searchfox.org/mozilla-central/rev/f82d5c549f046cb64ce5602bfd894b7ae807c8f8/accessible/generic/TableAccessible.cpp#19 + */ + private fun markDataTables(root: Element) { root.getElementsByTag("table").forEach outer@ { table -> val role = table.attr("role") if(role == "presentation") { - setReadabilityDataTable(table, false) + table._readabilityDataTable=false return@outer } val datatable = table.attr("datatable") if(datatable == "0") { - setReadabilityDataTable(table, false) + table._readabilityDataTable=false return@outer } val summary = table.attr("summary") if(summary.isNotBlank()) { - setReadabilityDataTable(table, true) + table._readabilityDataTable=true return@outer } val caption = table.getElementsByTag("caption") if(caption.size > 0 && caption[0].childNodeSize() > 0) { - setReadabilityDataTable(table, true) + table._readabilityDataTable=true return@outer } - // If the table has a descendant with any of these tags, consider a data table: (move to DATA_TABLE_DESCENDANTS to make code a more readable and a bit faster) - DATA_TABLE_DESCENDANTS.forEach { tag -> + + // If the table has a descendant with any of these tags, consider a data table + val dataTableDescendants = listOf("col", "colgroup", "tfoot", "thead", "th") + dataTableDescendants.forEach { tag -> if(table.getElementsByTag(tag).size > 0) { - log.debug("Data table because found data-y descendant") - setReadabilityDataTable(table, true) + log.info("Data table because found data-y descendant") + table._readabilityDataTable=true return@outer } } // Nested tables indicate a layout table: if(table.getElementsByTag("table").size > 0) { - setReadabilityDataTable(table, false) + table._readabilityDataTable= false return@outer } val sizeInfo = getRowAndColumnCount(table) + + if (sizeInfo.second == 1 || sizeInfo.first == 1) { + // single colum/row tables are commonly used for page layout purposes. + table._readabilityDataTable = false + return@outer + } + if (sizeInfo.first >= 10 || sizeInfo.second > 4) { - setReadabilityDataTable(table, true) + table._readabilityDataTable= true return@outer } // Now just go by size entirely: - setReadabilityDataTable(table, sizeInfo.first * sizeInfo.second > 10) + table._readabilityDataTable= (sizeInfo.first * sizeInfo.second > 10) } } /** * Return an object indicating how many rows and columns this table has. */ - protected open fun getRowAndColumnCount(table: Element): Pair { + private fun getRowAndColumnCount(table: Element): Pair { var rows = 0 var columns = 0 - val trs = table.getElementsByTag("tr") - trs.forEach { tr -> + table.getElementsByTag("tr").forEach { row -> rows += - try { - tr.attr("rowspan").toInt() - } catch(ignored: Exception) { - 1 - } + row.attr("rowspan") + .takeIf { "^\\d.".toRegex().matches(it) }?.toInt() ?:1 // Now look for column-related info var columnsInThisRow = 0 - tr.getElementsByTag("td").forEach { cell -> - columnsInThisRow += - try { - cell.attr("colspan").toInt() - } catch(ignored: Exception) { - 1 - } + row.getElementsByTag("td").forEach { cell -> + columnsInThisRow += cell.attr("colspan") + .takeIf { "^\\d.".toRegex().matches(it) }?.toInt() ?:1 } - columns = Math.max(columns, columnsInThisRow) + columns = max(columns, columnsInThisRow) } return Pair(rows, columns) } - protected open fun cleanConditionally(e: Element, tag: String, options: ArticleGrabberOptions) { - if(options.cleanConditionally == false) + private fun cleanConditionally(e: Element, tag: String) { + if(!options.cleanConditionally) return - val isList = tag == "ul" || tag == "ol" - // Gather counts for other typical elements embedded within. // Traverse backwards so we can remove nodes at the same time // without effecting the traversal. // // TODO: Consider taking into account original contentScore here. - removeNodes(e, tag) { node -> - // First check if we're in a data table, in which case don't remove us. - val isDataTable: (Element) -> Boolean = { element -> - getReadabilityDataTable(element) + removeNodes(e.getElementsByTag(tag)) filterFunction@ { node -> + + // First check if this node IS data table, in which case don't remove it. + val isDataTable: (Element) -> Boolean = { it._readabilityDataTable } + + var isList = tag == "ul" || tag == "ol" + + if (!isList) { + var listLength = 0 + val listNodes = node.getAllNodesWithTag(arrayOf("ul", "ol")) + listNodes.forEach{ list -> + listLength += getInnerText(list).length + } + val nodeTextLength = getInnerText(node).length + if (nodeTextLength!=0) + isList = listLength / nodeTextLength > 0.9 } + + if (tag == "table" && isDataTable(node)) { + return@filterFunction false + } + + // Next check if we're inside a data table, in which case don't remove it as well. if(hasAncestorTag(node, "table", -1, isDataTable)) { - return@removeNodes false + return@filterFunction false } - val weight = getClassWeight(node, options) - val contentScore = 0 + if (hasAncestorTag(node, "code")) { + return@filterFunction false + } - log.debug("Cleaning Conditionally {}", node) + // keep element if it has a data tables + if (node.getElementsByTag("table").any { tbl -> tbl._readabilityDataTable }) { + return@filterFunction false + } + + val weight = getClassWeight(node) + + log.info("Cleaning Conditionally {}", node.log()) + + val contentScore = 0 if(weight + contentScore < 0) { - return@removeNodes true + return@filterFunction true } if(getCharCount(node, ',') < 10) { @@ -959,47 +1186,146 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v val img = node.getElementsByTag("img").size val li = node.getElementsByTag("li").size - 100 val input = node.getElementsByTag("input").size + val headingDensity = getTextDensity(node, arrayOf( + "h1", + "h2", + "h3", + "h4", + "h5", + "h6" + )) var embedCount = 0 - node.getElementsByTag("embed").forEach { - if(regEx.isVideo(it.attr("src")) == false) { - embedCount += 1 + node.getAllNodesWithTag(arrayOf("object", "embed", "iframe")).forEach { embed-> + // If this embed has attribute that matches video regex, don't delete it. + if (embed.attributes().any { attr-> + attr.value.let { + regex.hasAllowedVideo(it) + } + }){ + return@filterFunction false } + // For embed with tag, check inner HTML as well. + if(embed.tagName() == "object" && + regex.hasAllowedVideo(embed.html()) + ){ + return@filterFunction false + } + + embedCount += 1 } + val innerText = getInnerText(node) + + // toss any node whose inner text contains nothing but suspicious words + if ( + regex.hasAdWords(innerText) || + regex.hasLoadingWords(innerText) + ) { + return@filterFunction true + } + + val contentLength = innerText.length val linkDensity = getLinkDensity(node) - val contentLength = getInnerText(node, regEx).length - - val haveToRemove = - (img > 1 && p / img.toFloat() < 0.5 && !hasAncestorTag(node, "figure")) || - (!isList && li > p) || - (input > Math.floor(p/3.0)) || - (!isList && contentLength < 25 && img == 0 && !hasAncestorTag(node, "figure")) || - (!isList && weight < 25 && linkDensity > 0.2) || - (weight >= 25 && linkDensity > 0.5) || - ((embedCount == 1 && contentLength < 75) || embedCount > 1) - return@removeNodes haveToRemove + val textishTags = arrayOf("span", "li", "td")+ DIV_TO_P_ELEMS + val textDensity = getTextDensity(node,textishTags) + val isFigureChild = hasAncestorTag(node,"figure") + val shouldRemoveNode: () -> Boolean = { + val errs= arrayListOf() + if (!isFigureChild && img > 1 && p.toDouble() / img.toDouble() < 0.5 ) { + errs.add("Bad p to img ratio (img=${img}, p=${p})") + } + if (!isList && li > p){ + errs.add("Too many li's outside of a list. (li=${li} > p=${p})") + } + if(input > floor(p/3.0)){ + errs.add("Too many inputs per p. (input=${input}, p=${p})") + } + if(!isList && + !isFigureChild && + headingDensity < 0.9 && + contentLength < 25 && + (img == 0 || img>2) && + linkDensity > 0) { + errs.add("Suspiciously short. (headingDensity=${headingDensity}, img=${img}, linkDensity=${linkDensity})") + } + if(!isList && weight < 25 && linkDensity > 0.2){ + errs.add("Low weight and a little linky. (linkDensity=${linkDensity})") + } + if(weight >= 25 && linkDensity > 0.5){ + errs.add("High weight and mostly links. (linkDensity=${linkDensity})") + } + if((embedCount == 1 && contentLength < 75) || embedCount > 1){ + errs.add("Suspicious embed. (embedCount=${embedCount}, contentLength=${contentLength})") + } + if(img == 0 && textDensity == 0.0){ + errs.add("No useful content. (img=${img}, textDensity=${textDensity})") + } + + if (errs.size>0){ + log.info("Checks failed {}",errs.joinToString(", ","["," ]")) + } + + errs.size!=0 + } + + val haveToRemove = shouldRemoveNode() + + if (isList && haveToRemove){ + node.children().forEach { child-> + // Don't filter in lists with li's that contain more than one child + if (child.children().size > 1) { + @Suppress("KotlinConstantConditions") + // just for make it "exact" as js code for the reader + return@filterFunction haveToRemove + } + } + val liCount = node.getElementsByTag("li").size + + // Only allow the list to remain if every li contains an image + if (img == liCount) { + return@filterFunction false + } + } + return@filterFunction haveToRemove } + return@filterFunction false + } + } - return@removeNodes false + private fun getTextDensity(e: Element, tags: Array): Double { + val textLength = getInnerText(e,true).length + if (textLength == 0) { + return 0.0 } + var childrenLength = 0.0 + val children = e.getAllNodesWithTag(tags).filterNot { it == e } + children.forEach{ + child -> + childrenLength += this.getInnerText(child, true).length + } + return childrenLength / textLength } /** * Check if a given node has one of its ancestor tag name matching the * provided one. */ - protected open fun hasAncestorTag(node: Element, tagName: String, maxDepth: Int = 3, filterFn: ((Element) -> Boolean)? = null): Boolean { - val tagNameLowerCase = tagName.toLowerCase() + private fun hasAncestorTag(node: Element, tagName: String, maxDepth: Int = 3, + filterFn: ((Element) -> Boolean) = { + true //bc you don't want a null exception + }): Boolean { var parent = node var depth = 0 while(parent.parent() != null) { - if(maxDepth > 0 && depth > maxDepth) { + if(maxDepth in 1.. + removeNodes(e.getElementsByTag(tag)) filterFunction@ { element -> // Allow youtube and vimeo videos through as people usually want to see those. if(isEmbed) { - val attributeValues = element.attributes().map { it.value }.joinToString("|") + val attributeValues = element.attributes().joinToString("|") { it.value } // First, check the elements attributes to see if any of them contain youtube or vimeo - if(regEx.isVideo(attributeValues)) { - return@removeNodes false + if(regex.hasAllowedVideo(attributeValues)) { + return@filterFunction false } - // Then check the elements inside this element for the same. - if(regEx.isVideo(element.html())) { - return@removeNodes false + // For embed with tag, check inner HTML as well. + if(element.tagName() == "object" && regex.hasAllowedVideo(element.html())) { + return@filterFunction false } } - return@removeNodes true + return@filterFunction true } } /** * Clean out elements whose id/class combinations match specific string. */ - protected open fun cleanMatchedNodes(e: Element, regex: Regex) { + private fun cleanMatchedNodes(e: Element,filterFn: (Element,String) -> Boolean) { val endOfSearchMarkerNode = getNextNode(e, true) var next = getNextNode(e) while(next != null && next != endOfSearchMarkerNode) { - if(regex.containsMatchIn(next.className() + " " + next.id())) { - next = removeAndGetNext(next, regex.pattern) - } - else { - next = getNextNode(next) + next = if(filterFn(next,next.className() + " " + next.id())) { + removeAndGetNext(next) + } else { + getNextNode(next) } } } @@ -1064,57 +1393,23 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v /** * Clean out spurious headers from an Element. Checks things like classnames and link density. */ - protected open fun cleanHeaders(e: Element, options: ArticleGrabberOptions) { - Arrays.asList("h1", "h2").forEach { - removeNodes(e, it) { header -> - getClassWeight(header, options) < 0 + private fun cleanHeaders(e: Element) { + removeNodes(e.getAllNodesWithTag(arrayOf("h1", "h2"))) { node -> + (getClassWeight(node) < 0).also { if (it) + log.info("Removing header with low class weight: {}", node.log() ) } } } - /* Util methods */ - - protected open fun removeAndGetNext(node: Element, reason: String = ""): Element? { - val nextNode = this.getNextNode(node, true) - printAndRemove(node, reason) - return nextNode - } - - /** - * Traverse the DOM from node to node, starting at the node passed in. - * Pass true for the second parameter to indicate this node itself - * (and its kids) are going away, and we want the next node over. - * - * Calling this in a loop will traverse the DOM depth-first. - */ - protected open fun getNextNode(node: Element, ignoreSelfAndKids: Boolean = false): Element? { - // First check for kids if those aren't being ignored - if(!ignoreSelfAndKids && node.children().size > 0) { - return node.child(0) - } - - // Then for siblings... - node.nextElementSibling()?.let { return it } - - // And finally, move up the parent chain *and* find a sibling - // (because this is depth-first traversal, we will have already - // seen the parent nodes themselves). - var parent = node.parent() - while(parent != null && parent.nextElementSibling() == null) { - parent = parent.parent() - } - - return parent?.nextElementSibling() - } - - protected open fun getTextDirection(topCandidate: Element, doc: Document) { + private fun getTextDirection(topCandidate: Element, doc: Document) { val ancestors = mutableSetOf(topCandidate.parent(), topCandidate) - ancestors.addAll(getNodeAncestors(topCandidate.parent()!!)) + ancestors.addAll(topCandidate.parent()?.let { getNodeAncestors(it) }?: listOf()) ancestors.add(doc.body()) ancestors.add(doc.selectFirst("html")) // needed as dir is often set on html tag ancestors.filterNotNull().forEach { ancestor -> + val articleDir = ancestor.attr("dir") if (articleDir.isNotBlank()) { this.articleDir = articleDir @@ -1124,16 +1419,25 @@ open class ArticleGrabber(protected val options: ReadabilityOptions, protected v } - protected open fun getReadabilityObject(element: Element): ReadabilityObject? { - return readabilityObjects[element] - } + private var Element.readability : ReadabilityObject? + set(value) { + if (value!=null) + readabilityObjects[this]=value + } + get(){ + return readabilityObjects[this] + } + + private var Element._readabilityDataTable : Boolean + set(value){ + readabilityDataTable[this]=value + } + get(){ + return readabilityDataTable[this] ?: false + } + - protected open fun getReadabilityDataTable(table: Element): Boolean { - return this.readabilityDataTable[table] ?: false - } - protected open fun setReadabilityDataTable(table: Element, readabilityDataTable: Boolean) { - this.readabilityDataTable.put(table, readabilityDataTable) - } } + diff --git a/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt b/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt index 20f72f3..9c67449 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/MetadataParser.kt @@ -1,106 +1,213 @@ package net.dankito.readability4j.processor +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper import net.dankito.readability4j.model.ArticleMetadata -import net.dankito.readability4j.util.RegExUtil +import net.dankito.readability4j.util.BaseRegexUtil import org.jsoup.nodes.Document -import java.util.regex.Pattern +import org.slf4j.LoggerFactory +import java.util.Deque +import java.util.Queue -open class MetadataParser(protected val regEx: RegExUtil = RegExUtil()): ProcessorBase() { +open class MetadataParser(override val regex: BaseRegexUtil = BaseRegexUtil()): ProcessorBase() { + private val log = LoggerFactory.getLogger(MetadataParser::class.java) - open fun getArticleMetadata(document: Document): ArticleMetadata { + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param document — the Document + * @param jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return ArticleMetadata with optional "excerpt" and "byline" properties + */ + open fun getArticleMetadata(document: Document,jsonld:ArticleMetadata?): ArticleMetadata { val metadata = ArticleMetadata() val values = HashMap() + val metaElements = document.getElementsByTag("meta"); - // Match "description", or Twitter's "twitter:description" (Cards) - // in name attribute. - val namePattern = Pattern.compile("^\\s*((twitter)\\s*:\\s*)?(description|title)\\s*$", Pattern.CASE_INSENSITIVE) + // property is a space-separated list of values + val propertyPattern = Regex("\\s*(article|dc|dcterm|og|twitter)\\s*:\\s*(author|creator|description|published_time|title|site_name)\\s*", RegexOption.IGNORE_CASE) - // Match Facebook's Open Graph title & description properties. - val propertyPattern = Pattern.compile("^\\s*og\\s*:\\s*(description|title)\\s*$", Pattern.CASE_INSENSITIVE) + // name is a single value + val namePattern = Regex("^\\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\\s*[-.:]\\s*)?(author|creator|pub-date|description|title|site_name)\\s*$", RegexOption.IGNORE_CASE) - document.select("meta").forEach { element -> + metaElements.forEach { element -> val elementName = element.attr("name") val elementProperty = element.attr("property") + val content = element.attr("content") - if(elementName == "author" || elementProperty == "author") { - metadata.byline = element.attr("content") + if (content.isBlank()){ return@forEach } var name: String? = null - if(namePattern.matcher(elementName).find()) { - name = elementName - } - else if(propertyPattern.matcher(elementProperty).find()) { - name = elementProperty - } - - if(name != null) { - val content = element.attr("content") - if(content.isNullOrBlank() == false) { + var matches:MatchResult?=null + if(elementProperty.isNotBlank()) { + matches=propertyPattern.find(elementProperty) + if (matches!=null) { // Convert to lowercase and remove any whitespace // so we can match below. - name = name.toLowerCase().replace("\\s".toRegex(), "") - values[name] = content.trim().replace(" ", " ") + name = matches.groupValues[0].lowercase() + .replace("\\s".toRegex(), "") + // multiple authors + values[name] = content.trim() } } + if(matches==null && elementName.isNotEmpty() && namePattern.matches(elementName)) { + // Convert to lowercase and remove any whitespace + // so we can match below. + name = elementName.lowercase() + .replace("\\s".toRegex(), "") + .replace('.',':') + values[name] = content.trim() + } } - metadata.excerpt = values["description"] ?: - values["og:description"] ?: // Use facebook open graph description. - values["twitter:description"] // Use twitter cards description. + // get title + metadata.title = jsonld?.title ?: + values["dc:title"] ?: + values["dcterm:title"] ?: + values["og:title"] ?: + values["weibo:article:title"] ?: + values["weibo:webpage:title"] ?: + values["title"] ?: + values["twitter:title"] ?: + values["parsely-title"] - metadata.title = getArticleTitle(document) - if(metadata.title.isNullOrBlank()) { - metadata.title = values["og:title"] ?: // Use facebook open graph title. - values["twitter:title"] // Use twitter cards title. - ?: "" + if (metadata.title==null) { + metadata.title = this.getArticleTitle(document) } - metadata.charset = document.charset()?.name() + val articleAuthor = if (values["article:author"]!=null && + !this.isUrl(values["article:author"])) values["article:author"] else null + + // get author + metadata.byline = jsonld?.byline ?: + values["dc:creator"] ?: + values["dcterm:creator"] ?: + values["author"] ?: + values["parsely-author"] ?: + articleAuthor + + // get description + metadata.excerpt = jsonld?.excerpt ?: + values["dc:description"] ?: + values["dcterm:description"] ?: + values["og:description"] ?: + values["weibo:article:description"] ?: + values["weibo:webpage:description"] ?: + values["description"] ?: + values["twitter:description"] + + // get site name + metadata.siteName = jsonld?.siteName ?: values["og:site_name"] + + // get article published time + metadata.publishedTime = jsonld?.datePublished ?: + values["article:published_time"] ?: + values["parsely-pub-date"] + + //not anymore + //metadata.charset = document.charset().name() + metadata.title = unescapeHtmlEntities(metadata.title) + metadata.byline = unescapeHtmlEntities(metadata.byline) + metadata.excerpt = unescapeHtmlEntities(metadata.excerpt) + metadata.siteName = unescapeHtmlEntities(metadata.siteName) + metadata.publishedTime = unescapeHtmlEntities(metadata.publishedTime) return metadata } - protected open fun getArticleTitle(doc: Document): String { + private fun unescapeHtmlEntities(str:String?):String? { + if (str==null) { + return null + } + + val htmlEscapeMap = mapOf( + "lt" to "<", + "gt" to ">", + "amp" to "&", + "quot" to "\"", + "apos" to "'", + ) + var unescaped = Regex("&(quot|amp|apos|lt|gt);").replace(str) { result -> + val tag = result.groupValues[1] + htmlEscapeMap[tag] ?: result.value + } + unescaped=Regex("&#(?:x([0-9a-f]+)|([0-9]+));",RegexOption.IGNORE_CASE) + .replace(unescaped) { result-> + val hex= result.groups[1]?.value + val numStr = result.groups[2]?.value + if (hex!=null||numStr!=null) { + + var num = hex?.toBigInteger(16)?.toInt() ?: numStr!!.toInt(10) + // these character references are replaced by a conforming HTML parser + if (num == 0 || + (num > 0x10ffff||num <0||(hex!=null&&hex.length>6)) || //Java max int limit + (num in 0xd800..0xdfff)) { + num = 0xfffd + } + + return@replace String(intArrayOf(num),0,1) + } + "\uD83D\uDE2D \uD83D\uDE2D � �" + "&#xg; &#x1F62D; &#128557; &#xFFFFFFFF; &#x0;" + result.value + } + return unescaped + + } + + /** + * Get the article title as an H1. + * + * @return string + **/ + private fun getArticleTitle(doc: Document): String { var curTitle = "" var origTitle = "" try { - origTitle = doc.title() + origTitle = doc.title().trim() curTitle = origTitle // If they had an element with id "title" in their HTML if(curTitle.isBlank()) { doc.select("#title").first()?.let { elementWithIdTitle -> - origTitle = getInnerText(elementWithIdTitle, regEx) + origTitle = getInnerText(elementWithIdTitle) curTitle = origTitle } } } catch(e: Exception) {/* ignore exceptions setting the title. */} var titleHadHierarchicalSeparators = false + val wordCount:( String)->Int= { str-> + str.split("\\s+".toRegex()).size + } // If there's a separator in the title, first remove the final part - if(curTitle.contains(" [\\|\\-\\/>»] ".toRegex())) { - titleHadHierarchicalSeparators = curTitle.contains(" [\\/>»] ".toRegex()) - curTitle = origTitle.replace("(.*)[\\|\\-\\/>»] .*".toRegex(RegexOption.IGNORE_CASE), "$1") + val titleSeparators = "|\\-–—\\\\\\/>»"; + if(curTitle.contains("\\s[${titleSeparators}]\\s".toRegex())) { + titleHadHierarchicalSeparators = curTitle.contains("\\s[\\\\/>»]\\s".toRegex()) + val allSeparators = ("\\s[${titleSeparators}]\\s".toRegex(setOf(RegexOption.IGNORE_CASE))).findAll(origTitle) + curTitle = origTitle.substring(0, allSeparators.last().range.last-1 ) // If the resulting title is too short (3 words or fewer), remove // the first part instead: if(wordCount(curTitle) < 3) { - curTitle = origTitle.replace("[^\\|\\-\\/>»]*[\\|\\-\\/>»](.*)".toRegex(RegexOption.IGNORE_CASE), "$1") + curTitle = origTitle.replace("^[^|\\-\\\\/>»]*[|\\-\\\\/>»]".toRegex(RegexOption.IGNORE_CASE), "") } } else if(curTitle.contains(": ")) { // Check if we have an heading containing this exact string, so we // could assume it's the full title. - val match = doc.select("h1, h2").any { it.wholeText() == curTitle } + val match = doc.getAllNodesWithTag(arrayOf("h1","h2")).any { it.wholeText().trim() == curTitle.trim() } // If we don't, let's extract the title out of the original title string. - if(match == false) { + if(!match) { curTitle = origTitle.substring(origTitle.lastIndexOf(':') + 1) // If the title is now too short, try the first colon instead: @@ -118,11 +225,11 @@ open class MetadataParser(protected val regEx: RegExUtil = RegExUtil()): Process val hOnes = doc.getElementsByTag("h1") if(hOnes.size == 1) { - curTitle = getInnerText(hOnes[0], regEx) + curTitle = getInnerText(hOnes[0]) } } - curTitle = curTitle.trim() + curTitle = regex.normalize(curTitle.trim()) // If we now have 4 words or fewer as our title, and either no // 'hierarchical' separators (\, /, > or ») were found in the original // title or we decreased the number of words by more than 1 word, use @@ -130,15 +237,147 @@ open class MetadataParser(protected val regEx: RegExUtil = RegExUtil()): Process val curTitleWordCount = wordCount(curTitle) if(curTitleWordCount <= 4 && (!titleHadHierarchicalSeparators || - curTitleWordCount != wordCount(origTitle.replace("[\\|\\-\\/>»]+".toRegex(), "")) - 1)) { + curTitleWordCount != wordCount(origTitle.replace("\\s[${titleSeparators}]\\s".toRegex(), "")) - 1)) { curTitle = origTitle } return curTitle } - protected open fun wordCount(str: String): Int { - return str.split("\\s+".toRegex()).size + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + open fun getJSONLD(doc: Document):ArticleMetadata?{ + var metadata:ArticleMetadata?=null + + doc.getElementsByTag("script").forEach{ jsonLdElement-> + if ( + metadata==null && + jsonLdElement.attr("type") == "application/ld+json" + ) { + + try { + // Strip CDATA markers if present + val content = jsonLdElement.html().replace( + Regex("^\\s*\\s*$"), + "" + ) + + var parsed = ObjectMapper().readTree(content) ?: return@forEach + + if (parsed.isArray) { + parsed = parsed.jsFind{ node -> + node.has("@type") && + regex.isJsonLDArticle(node.get("@type").textValue()) + }?:return@forEach + } + + val schemaDotOrgRegex = Regex("^https?://schema\\.org/?$") + val matches = + (parsed["@context"]?.isTextual?.let { + if (it) + schemaDotOrgRegex.containsMatchIn(parsed["@context"].textValue()) + else false + }==true) || + (parsed.get("@context")?.isObject?.let { + if (it) parsed["@context"]["@vocab"]?.isTextual?.let { + schemaDotOrgRegex.containsMatchIn(parsed["@context"]["@vocab"].textValue()) + }==true // we dont want a null here + else false + }==true) + + if (!matches) { + return@forEach + } + + if (parsed["@type"]==null && parsed["@graph"]?.let{parsed["@graph"].isArray}==true) { + parsed = parsed["@graph"].jsFind{ node -> + node.has("@type") && + regex.isJsonLDArticle(node.get("@type").textValue()) + }?: return@forEach + } + + if ( + parsed["@type"]==null || + !regex.isJsonLDArticle(parsed["@type"].textValue()) + ) { + return@forEach + } + + val nonNullMetadata = ArticleMetadata() + + if ( + parsed["name"]?.isTextual == true && + parsed["headline"]?.isTextual == true && + parsed["name"] != parsed["headline"] + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + val title = getArticleTitle(doc) + val nameMatches = textSimilarity(parsed["name"].textValue(), title) > 0.75 + val headlineMatches = textSimilarity(parsed["headline"].textValue(), title) > 0.75 + + if (headlineMatches && !nameMatches) { + nonNullMetadata.title = parsed["headline"].textValue() + } else { + nonNullMetadata.title = parsed["name"].textValue() + } + } else if (parsed["name"]?.isTextual == true) { + nonNullMetadata.title = parsed["name"].textValue().trim() + } else if (parsed["headline"]?.isTextual == true) { + nonNullMetadata.title = parsed["headline"].textValue().trim() + } + if (parsed.hasNonNull("author") ) { + if (parsed["author"]?.get("name")?.isTextual == true) { + nonNullMetadata.byline = parsed["author"].get("name").textValue().trim() + } else if ( + parsed["author"].isArray && + parsed["author"].get(0)?.get("name")?.isTextual == true + ) { + nonNullMetadata.byline = parsed["author"].filter { author -> + author?.get("name")?.isTextual == true + }.joinToString(", ") { author -> + author["name"].textValue().trim() + } + } + } + if (parsed["description"]?.isTextual == true) { + nonNullMetadata.excerpt = parsed["description"].textValue().trim() + } + if (parsed["publisher"]?.isObject==true && + parsed["publisher"]?.get("name")?.isTextual == true) { + nonNullMetadata.siteName = parsed["publisher"]["name"].textValue().trim() + } + if (parsed["datePublished"]?.isTextual == true) { + nonNullMetadata.datePublished = parsed["datePublished"].textValue().trim() + } + metadata=nonNullMetadata + } catch (err:Exception) { + log.error("{}\n\n{}",err.message,err.stackTraceToString()) + println(err.message+"\n\n"+err.stackTraceToString()) + } + } + } + + return metadata } -} \ No newline at end of file + /** + * Array.Prototype.find (this should work like that one) + * + * @return null if don't find in the array + * */ + private fun JsonNode.jsFind(filterFun:(JsonNode)->Boolean):JsonNode?{ + if (this.isArray){ + for (value in this){ + if (filterFun(value)) + return value + } + } + return null + } +} diff --git a/src/main/kotlin/net/dankito/readability4j/processor/Postprocessor.kt b/src/main/kotlin/net/dankito/readability4j/processor/Postprocessor.kt index accf80d..fe1f992 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/Postprocessor.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/Postprocessor.kt @@ -1,142 +1,215 @@ package net.dankito.readability4j.processor -import org.jsoup.nodes.Document +import net.dankito.readability4j.model.ReadabilityOptions +import net.dankito.readability4j.util.BaseRegexUtil import org.jsoup.nodes.Element import org.jsoup.nodes.TextNode import org.slf4j.LoggerFactory import java.net.URI -import java.util.Arrays -import java.util.regex.Pattern +import java.net.URISyntaxException -open class Postprocessor { - - companion object { - val AbsoluteUriPattern = Pattern.compile("^[a-zA-Z][a-zA-Z0-9\\+\\-\\.]*:") - +open class Postprocessor(override val regex:BaseRegexUtil= BaseRegexUtil()):ProcessorBase() { + companion object { // These are the classes that readability sets itself. - val CLASSES_TO_PRESERVE = Arrays.asList("readability-styled", "page") - + val CLASSES_TO_PRESERVE = listOf("page") private val log = LoggerFactory.getLogger(Postprocessor::class.java) } - - open fun postProcessContent(originalDocument: Document, articleContent: Element, articleUri: String, - additionalClassesToPreserve: Collection = emptyList()) { + open fun postProcessContent(articleContent: Element,baseUri: String, documentUri: String, + options: ReadabilityOptions) { + // Readability cannot open relative uris so we convert them to absolute uris. - fixRelativeUris(originalDocument, articleContent, articleUri) + fixRelativeUris(articleContent, baseUri, documentUri) + simplifyNestedElements(articleContent) - // Remove IDs and classes. - // Remove classes. - val classesToPreserve = Arrays.asList(CLASSES_TO_PRESERVE, additionalClassesToPreserve).flatten().toSet() - cleanClasses(articleContent, classesToPreserve) + if (!options.keepClasses) { + // Remove classes. + this.cleanClasses(articleContent,options.additionalClassesToPreserve) + } } + private fun simplifyNestedElements(articleContent: Element) { + var node:Element?= articleContent + + while (node!=null){ + if ( + node.parentNode()!=null && + node.tagName() in arrayOf("div", "section") && + !(node.id().isNotBlank() && node.id().startsWith("readability")) + ) { + if (this.isElementWithoutContent(node)) { + node = this.removeAndGetNext(node) + continue + } else if ( + this.hasSingleTagInsideElement(node, "div") || + this.hasSingleTagInsideElement(node, "section") + ) { + val child = node.children()[0] + for (i in node.attributes()){ + child.attr(i.key,i.value) + } + node.replaceWith(child) + node = child + continue + } + } - /** - * Converts each and uri in the given element to an absolute URI, - * ignoring #ref URIs. - */ - protected open fun fixRelativeUris(originalDocument: Document, element: Element, articleUri: String) { - try { - val uri = URI.create(articleUri) - val scheme = uri.scheme - val prePath = uri.scheme + "://" + uri.host - val pathBase = uri.scheme + "://" + uri.host + uri.path.substring(0, uri.path.lastIndexOf("/") + 1) - - fixRelativeUris(originalDocument, element, scheme, prePath, pathBase) - } catch(e: Exception) { log.error("Could not fix relative urls for $element with base uri $articleUri", e) } + node = getNextNode(node) + } } - protected open fun fixRelativeUris(originalDocument: Document, element: Element, scheme: String, prePath: String, - pathBase: String) { - fixRelativeAnchorUris(element, scheme, prePath, pathBase) - - fixRelativeImageUris(element, scheme, prePath, pathBase) + /** + * Converts each and uri in the given element to an absolute uri, + * ignoring #ref uris. + */ + open fun fixRelativeUris(element: Element, baseUri: String, documentUri: String) { + try { + var realBaseUri=baseUri + //this because if no base tag the same behavior is in base javascript + if (baseUri.isBlank()&&documentUri.isBlank()) + return //nothing to do if no documentUri neither base tag + else if (baseUri.isBlank()) { + realBaseUri = documentUri + } + if (URI(realBaseUri).isAbsolute){ + fixRelativeAnchorUris(element, realBaseUri,documentUri) + fixRelativeImageUris(element, realBaseUri,documentUri) + } + }catch (e: URISyntaxException ){ + //this one is just the java variant of the error just in case + log.error("Could not fix relative uri for element:$element with base uri documentUri:$documentUri because it don't look a valid uri", e) + } catch(e: Exception) { + log.error("Could not fix relative uri for $element with base uri $documentUri", e) + } } - protected open fun fixRelativeAnchorUris(element: Element, scheme: String, prePath: String, pathBase: String) { + protected open fun fixRelativeAnchorUris(element: Element, baseURI:String, documentURI:String) { element.getElementsByTag("a").forEach { link -> - val href = link.attr("href") + val href = link.attr("href").trim() if(href.isNotBlank()) { // Replace links with javascript: URIs with text content, since // they won't work after scripts have been removed from the page. if(href.indexOf("javascript:") == 0) { - val text = TextNode(link.wholeText()) - link.replaceWith(text) + if ( + link.childNodes().size == 1 && + link.childNodes()[0] is TextNode + ) { + val text = TextNode(link.wholeText()) + link.replaceWith(text) + }else{ + // if the link has multiple children, they should all be preserved + val container = Element("span") + while (link.firstChild()!=null) { + link.firstChild()?.let { container.appendChild(it) } + } + link.replaceWith(container) + } } else { - link.attr("href", toAbsoluteURI(href, scheme, prePath, pathBase)) + link.attr("href", toAbsoluteURI(href, baseURI,documentURI)) } } } } - protected open fun fixRelativeImageUris(element: Element, scheme: String, prePath: String, pathBase: String) { - element.getElementsByTag("img").forEach { img -> - fixRelativeImageUri(img, scheme, prePath, pathBase) - } - } + protected open fun fixRelativeImageUris(element: Element, baseUri:String, docUri: String) { + val medias = element.getAllNodesWithTag(arrayOf( + "img", + "picture", + "figure", + "video", + "audio", + "source", + )) + + medias.forEach { media -> + val src = media.attr("src").trim() + val poster = media.attr("poster").trim() + val srcset = media.attr("srcset").trim() + + if(src.isNotBlank()) { + media.attr("src", toAbsoluteURI(src,baseUri,docUri)) + } - protected open fun fixRelativeImageUri(img: Element, scheme: String, prePath: String, pathBase: String) { - val src = img.attr("src") + if (poster.isNotBlank()) { + media.attr("poster", toAbsoluteURI(poster,baseUri,docUri)) + } + if (srcset.isNotBlank()) { + var newSrcset = "" + regex.getSrcSetMatches(srcset).map { it.groups }.forEach { group -> + val srcSetBaseUri=group[1]?.value + val srcSetSize=group[2]?.value + val srcSetSeparator=group[3]?.value + if (srcSetBaseUri!=null&&srcSetSeparator!=null){ + newSrcset+= toAbsoluteURI(srcSetBaseUri,baseUri,docUri)+(srcSetSize?:"")+srcSetSeparator + } + } - if(src.isNotBlank()) { - img.attr("src", toAbsoluteURI(src, scheme, prePath, pathBase)) + media.attr("srcset", newSrcset) + } } } - protected open fun toAbsoluteURI(uri: String, scheme: String, prePath: String, pathBase: String): String { - // If this is already an absolute URI, return it. - if(isAbsoluteUri(uri) || uri.length <= 2) { - return uri - } - - // Scheme-rooted relative URI. - if(uri.substring(0, 2) == "//") { - return scheme + "://" + uri.substring(2) - } + protected open fun toAbsoluteURI(uri: String, baseURI:String, documentURI:String): String { - // Prepath-rooted relative URI. - if(uri[0] == '/') { - return prePath + uri - } - - // Dotslash relative URI. - if(uri.indexOf("./") == 0) { - return pathBase + uri.substring(2) + // Leave hash links alone if the base URI matches the document URI: + if(baseURI==documentURI && uri[0] == '#') { + return uri } - // Ignore hash URIs: - if(uri[0] == '#') { - return uri + // Otherwise, resolve against base URI: + try { + //Zero width space breaks the Java URI match algorithm and and Redability.js just don't mind it + //in really really weird cases it can be in the url + //at least the 95% of code here is because javascript and java resolves god know how the uris but different + if(uri.startsWith("\u200B")||uri.startsWith("%E2%80%8B")){ + return URI(baseURI).resolve("").toString()+uri + }else if(uri.contains(Regex("^\\.\\./\\.\\./\\.\\./(\\.\\./)+"))){ + return URI(baseURI).resolve(uri.replace(Regex("^(\\.\\./)+"),"../../")).toString() + }else if(uri.contains(Regex("^file:"))){ + return uri.replaceFirst("file:///","file:/") //dont resolve file uris + }else { + val realUri = URI(baseURI).resolve(uri).toString() + + //also add to the latest part the / just for testing proposes as URI doesn't add it + val isOnlyFirstPart = Regex("^(?:http|https)://[a-zA-Z.0-9-_]+(?::\\d+)?(/)?\$") + return if (isOnlyFirstPart.matches(uri) && uri.last() != '/') { + "$realUri/" + } else { + val firstPartNeedsSlash = Regex("^(?:http|https)://[a-zA-Z.0-9-_]+(?::\\d+)?(/)?") + if(firstPartNeedsSlash.find(realUri)?.groups?.let { it[1] ==null }==true) + firstPartNeedsSlash.replace(realUri,"$0/") + else + realUri + } + } + } catch (_: Exception) { + // Something went wrong, just return the original: } - // Standard relative URI add entire path. pathBase already includes a - // trailing "/". - return pathBase + uri + return uri } - - protected open fun isAbsoluteUri(uri: String): Boolean { - return AbsoluteUriPattern.matcher(uri).find() - } - /** * Removes the class="" attribute from every element in the given * subtree, except those that match CLASSES_TO_PRESERVE and * the classesToPreserve array from the options object. + * + * @param node Element to clean it and their children + * @param classesToPreserve Set of Strings of ReadabilityOptions.classesToPreserve + * @return void */ protected open fun cleanClasses(node: Element, classesToPreserve: Set) { - val classNames = node.classNames().filter { classesToPreserve.contains(it) } + val classNames = node.classNames().filter { it in (classesToPreserve + CLASSES_TO_PRESERVE) } if(classNames.isNotEmpty()) { node.classNames(classNames.toMutableSet()) } else { - node.removeAttr("class") + node.classNames(setOf()) } node.children().forEach { child -> @@ -144,4 +217,4 @@ open class Postprocessor { } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt index 6171415..7507c0a 100644 --- a/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt +++ b/src/main/kotlin/net/dankito/readability4j/processor/Preprocessor.kt @@ -1,6 +1,6 @@ package net.dankito.readability4j.processor -import net.dankito.readability4j.util.RegExUtil +import net.dankito.readability4j.util.BaseRegexUtil import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.Node @@ -9,11 +9,10 @@ import org.slf4j.LoggerFactory /** * Performs basic sanitization before starting the extraction process. */ -open class Preprocessor(protected val regEx: RegExUtil = RegExUtil()) : ProcessorBase() { +open class Preprocessor(override val regex: BaseRegexUtil = BaseRegexUtil()) : ProcessorBase() { + + private val log = LoggerFactory.getLogger(Preprocessor::class.java) - companion object { - private val log = LoggerFactory.getLogger(Preprocessor::class.java) - } /** @@ -24,40 +23,134 @@ open class Preprocessor(protected val regEx: RegExUtil = RegExUtil()) : Processo log.debug("Starting to prepare document") removeScripts(document) - removeNoscripts(document); - removeStyles(document) + removeNodes(document.getElementsByTag("style")) - removeForms(document) // TODO: this is not in Mozilla's Readability +// removeForms(document) // TODO: this was moved in Mozilla's Readability to on grabArticle - removeComments(document) // TODO: this is not in Mozilla's Readability +// removeComments(document) // TODO: this is not in Mozilla's Readability now - replaceBrs(document, regEx) + replaceBrs(document) - replaceNodes(document, "font", "span") + replaceNodeTags(document.getElementsByTag("font"), "span") } + /** + * Removes script tags from the document. + * + * @param document + **/ + private fun removeScripts(document: Document) { + removeNodes(document.getAllNodesWithTag(arrayOf("script","noscript"))) + } - protected open fun removeScripts(document: Document) { - removeNodes(document, "script") { scriptNode -> - scriptNode.`val`(null) // TODO: what is this good for? - scriptNode.removeAttr("src") - true + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param noscript Element + **/ + private fun isSingleImage(noscript: Element): Boolean { + var element:Element?=noscript + while (element!=null){ + if (element.tagName() == "img") { + return true + } + if (element.children().size != 1 || element.wholeText().trim() != ""){ + return false + } + element = element.child(0) } + return false } - protected open fun removeNoscripts(document: Document) { - document.getElementsByTag("noscript").forEach { noscript -> - if(shouldKeepImageInNoscriptElement(document, noscript)) { // TODO: this is not in Mozilla's Readability - noscript.unwrap() + /** + * Find all