givememd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
givememd/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ import re
2
+
3
+ from .extract import convert
4
+ from .fetch import TIERS, UnsafeURL, fetch
5
+
6
+
7
+ def read(target, links=True, tiers=None):
8
+ """URL -> (markdown, Result). Raises UnsafeURL for private/invalid targets."""
9
+ r = fetch(target, tiers=tiers)
10
+ if r.partial:
11
+ return convert(r.body, r.url, links=False, meta_only=True), r
12
+ if not r.ok:
13
+ return None, r
14
+ if 'markdown' in r.content_type:
15
+ return clean_site_markdown(r.body), r
16
+ md = convert(r.body, r.url, links=links)
17
+ later = [n for n, _ in TIERS[[n for n, _ in TIERS].index(r.tier) + 1:]] if r.tier in [n for n, _ in TIERS] else []
18
+ if later and len(body_text(md)) < 200 and r.body.count('<script') >= 3:
19
+ # the page "loaded" but its content is rendered by JavaScript (streamed payloads, app shells): render it
20
+ r2 = fetch(target, tiers=later, use_cache=False)
21
+ if r2.ok and 'markdown' not in r2.content_type:
22
+ md2 = convert(r2.body, r2.url, links=links)
23
+ if len(body_text(md2)) > len(body_text(md)):
24
+ r2.trace = r.trace + ['extraction was empty; rendered'] + r2.trace
25
+ return md2, r2
26
+ return md, r
27
+
28
+
29
+ def clean_site_markdown(md):
30
+ """Site-served markdown is often MDX: drop heading anchors and bare component tags, keep their content."""
31
+ md = re.sub(r'\s*\{/\*.*?\*/\}', '', md)
32
+ md = re.sub(r'(?m)^[ \t]*</?[A-Z][\w.]*(\s[^>]*)?/?>[ \t]*\n', '', md)
33
+ return re.sub(r'\n{3,}', '\n\n', md)
34
+
35
+
36
+ def body_text(md):
37
+ """Text after the frontmatter, without markdown punctuation."""
38
+ parts = md.split('---', 2)
39
+ return re.sub(r'[#*_`>|\-\s]+', ' ', parts[2] if len(parts) == 3 else md).strip()
givememd/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
givememd/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ """givememd <url> [--no-links] [--trace] -- print LLM-ready markdown for a web page (fetched from this machine)."""
2
+ import sys
3
+
4
+ from . import UnsafeURL, read
5
+
6
+
7
+ def main():
8
+ args = [a for a in sys.argv[1:] if not a.startswith('--')]
9
+ if not args or '--help' in sys.argv or '-h' in sys.argv:
10
+ print(__doc__.strip()); sys.exit(0 if args or '--help' in sys.argv or '-h' in sys.argv else 2)
11
+ try:
12
+ md, r = read(args[0], links='--no-links' not in sys.argv)
13
+ except UnsafeURL as e:
14
+ sys.exit(f'refused: {e}')
15
+ if '--trace' in sys.argv:
16
+ print('\n'.join(r.trace), file=sys.stderr)
17
+ if md is None:
18
+ sys.exit(f'could not read {r.url}')
19
+ print(md)
20
+
21
+
22
+ if __name__ == '__main__':
23
+ main()
givememd/components.py ADDED
@@ -0,0 +1,189 @@
1
+ """Component catalog: every section of a web page is matched to one of these, filled, and rendered.
2
+
3
+ Each component has a category (first-stage choice), a short description (the decision model's criterion),
4
+ a shape (how it is filled from the DOM and rendered), the fields it carries, and the JSON-LD types that can
5
+ fill it directly. Shapes:
6
+
7
+ header title + one line of facts kv key: value pairs
8
+ bullets list of short items prose paragraphs
9
+ steps numbered list qa question / answer pairs
10
+ table rows and columns cards repeated items -> one table
11
+ code code with surrounding text links titled links (rendered only if asked)
12
+ drop not content; never rendered
13
+ """
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Component:
19
+ name: str
20
+ category: str
21
+ desc: str
22
+ shape: str
23
+ fields: tuple = ()
24
+ ld: tuple = () # JSON-LD @types that fill this component
25
+ heading: str = '' # section heading when rendered ('' = the section's own heading)
26
+
27
+
28
+ def C(name, category, desc, shape, fields='', ld='', heading=''):
29
+ return Component(name, category, desc, shape, tuple(fields.split()), tuple(ld.split()), heading)
30
+
31
+
32
+ CATEGORIES = {
33
+ 'commerce': 'a product for sale: its name, price, options, specs, shipping, reviews',
34
+ 'listings': 'many items listed together: search results, category pages, homes, jobs, events, people',
35
+ 'article': 'news, blog, essay or story content: headline, byline, body text, quotes, images',
36
+ 'docs': 'technical documentation, API references, code, tutorials, changelogs',
37
+ 'community': 'forum threads, questions and answers, comments, social posts',
38
+ 'profile': 'a person, company, place or channel: name, bio, stats, contact',
39
+ 'media': 'video, podcast, music, film, book or game details',
40
+ 'events_travel': 'events, tickets, hotels, flights, rentals, itineraries',
41
+ 'food_health': 'recipes, nutrition, menus, medical or fitness information',
42
+ 'data_finance': 'tables, statistics, prices, charts, financial quotes, comparisons',
43
+ 'business': 'company marketing: features, pricing plans, testimonials, FAQs, calls to action',
44
+ 'reference': 'encyclopedia or knowledge content: infobox, definitions, timelines, citations',
45
+ 'chrome': 'not content: navigation, footers, ads, cookie banners, login prompts, UI text',
46
+ }
47
+
48
+ CATALOG = [
49
+ # ---- commerce
50
+ C('product_header', 'commerce', 'main product name, brand, price, rating', 'header', 'name brand price list_price rating rating_count availability', 'Product'),
51
+ C('price_offer', 'commerce', 'price, discount, sellers, deal or offer details', 'kv', 'price list_price discount seller condition', 'Offer AggregateOffer'),
52
+ C('variant_picker', 'commerce', 'color, size, style or configuration options', 'bullets', 'option values', heading='Options'),
53
+ C('spec_table', 'commerce', 'product specifications as key/value pairs', 'kv', 'specs', heading='Specs'),
54
+ C('feature_bullets', 'commerce', 'bullet list of product features or highlights', 'bullets', 'items', heading='Features'),
55
+ C('product_description', 'commerce', 'paragraphs describing the product', 'prose', 'text', heading='Description'),
56
+ C('whats_in_box', 'commerce', "list of what's included in the box", 'bullets', 'items', heading="What's in the box"),
57
+ C('shipping_returns', 'commerce', 'delivery, shipping, returns or warranty terms', 'kv', 'shipping returns warranty', heading='Shipping & returns'),
58
+ C('stock_status', 'commerce', 'in stock, sold out, quantity left, bought recently', 'kv', 'availability'),
59
+ C('review_summary', 'commerce', 'average rating, rating histogram, review topics', 'kv', 'rating count distribution topics', 'AggregateRating', 'Reviews'),
60
+ C('review_item', 'commerce', 'one customer review: author, stars, date, text', 'prose', 'author rating date title text', 'Review', 'Review'),
61
+ C('size_guide', 'commerce', 'size chart or fit guidance', 'table', 'rows', heading='Size guide'),
62
+ C('product_comparison', 'commerce', 'table comparing several products side by side', 'table', 'rows', heading='Comparison'),
63
+ C('bundle_offer', 'commerce', 'frequently bought together or bundle deal', 'links', 'items'),
64
+ C('seller_info', 'commerce', 'seller or store name, ratings, policies', 'kv', 'name rating', 'Store', 'Seller'),
65
+ # ---- listings
66
+ C('listing_grid', 'listings', 'grid or list of many products, homes, results', 'cards', 'items', 'ItemList', 'Results'),
67
+ C('listing_card', 'listings', 'one item in a listing: title, price, key facts', 'kv', 'title price facts'),
68
+ C('result_count', 'listings', 'number of results, sort order, page info', 'kv', 'count sort'),
69
+ C('filter_panel', 'listings', 'search filters and facets', 'drop'),
70
+ C('pagination', 'listings', 'page numbers, next/previous links', 'drop'),
71
+ C('real_estate_listing', 'listings', 'one home or rental: price, beds, baths, area, address', 'kv', 'address price beds baths area status', 'SingleFamilyResidence Apartment House Residence RealEstateListing'),
72
+ C('job_posting', 'listings', 'job title, company, location, salary, description', 'kv', 'title company location salary type posted', 'JobPosting'),
73
+ C('search_box', 'listings', 'search input and suggestions', 'drop'),
74
+ C('map_widget', 'listings', 'map with pins or location picker', 'drop'),
75
+ # ---- article
76
+ C('article_header', 'article', 'headline, byline, date, standfirst of an article', 'header', 'headline author date standfirst', 'NewsArticle Article BlogPosting Report'),
77
+ C('article_body', 'article', 'paragraphs of article or blog text', 'prose', 'text'),
78
+ C('subheading_section', 'article', 'subheading followed by its paragraphs', 'prose', 'text'),
79
+ C('pull_quote', 'article', 'highlighted quotation from the article', 'prose', 'text'),
80
+ C('image_caption', 'article', 'caption or credit for a photo or figure', 'prose', 'text'),
81
+ C('author_bio', 'article', 'short biography of the author', 'prose', 'text', heading='About the author'),
82
+ C('key_takeaways', 'article', 'summary bullets, TL;DR, key points', 'bullets', 'items', heading='Key points'),
83
+ C('table_of_contents', 'article', 'list of sections in this page', 'drop'),
84
+ C('related_articles', 'article', 'links to other stories: most viewed, read next, related', 'links', 'items'),
85
+ C('newsletter_signup', 'article', 'subscribe to a newsletter prompt', 'drop'),
86
+ C('correction_note', 'article', 'correction, update or editor note', 'prose', 'text'),
87
+ C('live_blog_entry', 'article', 'timestamped live blog update', 'prose', 'time text', 'LiveBlogPosting'),
88
+ C('embedded_post', 'article', 'embedded social media post inside an article', 'prose', 'author text'),
89
+ # ---- docs
90
+ C('doc_header', 'docs', 'title and summary of a documentation page', 'header', 'title summary', 'TechArticle'),
91
+ C('api_signature', 'docs', 'function, method or endpoint signature', 'code', 'signature'),
92
+ C('parameter_list', 'docs', 'parameters or arguments with descriptions', 'kv', 'params', heading='Parameters'),
93
+ C('return_value', 'docs', 'what a function returns', 'prose', 'text', heading='Returns'),
94
+ C('code_example', 'docs', 'code snippet, possibly with explanation', 'code', 'code text'),
95
+ C('doc_prose', 'docs', 'explanatory documentation text', 'prose', 'text'),
96
+ C('callout_note', 'docs', 'note, warning, tip or pitfall box', 'prose', 'kind text'),
97
+ C('install_instructions', 'docs', 'install or setup commands', 'code', 'code text', heading='Install'),
98
+ C('changelog_entry', 'docs', 'release notes for a version', 'bullets', 'version date items'),
99
+ C('compat_table', 'docs', 'browser or version compatibility table', 'table', 'rows', heading='Compatibility'),
100
+ C('readme_badges', 'docs', 'status badges and shields', 'drop'),
101
+ C('repo_header', 'docs', 'code repository name, description, stars, forks', 'header', 'name description stars forks language', 'SoftwareSourceCode'),
102
+ C('file_tree', 'docs', 'list of files and folders in a repository', 'drop'),
103
+ C('cli_reference', 'docs', 'command line usage and flags', 'code', 'code text'),
104
+ # ---- community
105
+ C('thread_header', 'community', 'forum or Q&A question title, author, stats', 'header', 'title author date votes answers views', 'QAPage DiscussionForumPosting'),
106
+ C('post_body', 'community', 'body of a forum post or question', 'prose', 'author date text'),
107
+ C('answer', 'community', 'one answer or reply with author and votes', 'prose', 'author votes date text', 'Answer Comment'),
108
+ C('comment_list', 'community', 'comments under a post', 'prose', 'items', heading='Comments'),
109
+ C('social_post', 'community', 'tweet, post or status with author and counts', 'prose', 'author date text likes reposts replies', 'SocialMediaPosting'),
110
+ C('poll', 'community', 'poll question and options with votes', 'kv', 'question options'),
111
+ C('community_rules', 'community', 'rules or guidelines of a forum or group', 'bullets', 'items', heading='Rules'),
112
+ C('subreddit_header', 'community', 'community or group name, members, description', 'header', 'name members description'),
113
+ # ---- profile
114
+ C('profile_header', 'profile', 'person or account name, handle, bio, follower counts', 'header', 'name handle bio followers following posts', 'Person ProfilePage'),
115
+ C('org_header', 'profile', 'company or organization name, industry, size, HQ', 'header', 'name industry size headquarters founded website', 'Organization Corporation'),
116
+ C('place_header', 'profile', 'business or place name, address, hours, rating', 'header', 'name address phone hours rating', 'LocalBusiness Restaurant Place'),
117
+ C('experience_list', 'profile', 'jobs or roles held with dates', 'bullets', 'items', heading='Experience'),
118
+ C('education_list', 'profile', 'schools and degrees', 'bullets', 'items', heading='Education'),
119
+ C('skills_list', 'profile', 'skills, tags or specialties', 'bullets', 'items', heading='Skills'),
120
+ C('contact_info', 'profile', 'email, phone, address, social links', 'kv', 'email phone address', heading='Contact'),
121
+ C('opening_hours', 'profile', 'opening hours by day', 'kv', 'hours', heading='Hours'),
122
+ C('post_grid', 'profile', "grid of an account's posts or videos", 'cards', 'items', heading='Posts'),
123
+ C('people_list', 'profile', 'list of people, employees, followers or members', 'cards', 'items', heading='People'),
124
+ # ---- media
125
+ C('video_header', 'media', 'video title, channel, views, date, description', 'header', 'title channel views date duration description', 'VideoObject'),
126
+ C('podcast_episode', 'media', 'podcast or episode title, show, date, summary', 'header', 'title show date duration summary', 'PodcastEpisode'),
127
+ C('film_header', 'media', 'film or show title, year, rating, director, cast', 'header', 'title year rating director cast genre', 'Movie TVSeries'),
128
+ C('book_header', 'media', 'book title, author, rating, pages, published', 'header', 'title author rating pages published', 'Book'),
129
+ C('music_header', 'media', 'song or album title, artist, release', 'header', 'title artist release', 'MusicRecording MusicAlbum'),
130
+ C('game_header', 'media', 'game or app name, developer, rating, platforms', 'header', 'name developer rating platforms', 'VideoGame SoftwareApplication MobileApplication'),
131
+ C('cast_list', 'media', 'cast and crew list', 'bullets', 'items', heading='Cast'),
132
+ C('episode_list', 'media', 'list of episodes or tracks', 'cards', 'items', heading='Episodes'),
133
+ C('transcript', 'media', 'transcript or lyrics text', 'prose', 'text', heading='Transcript'),
134
+ C('genre_tags', 'media', 'genres, tags or categories', 'bullets', 'items', heading='Genres'),
135
+ C('media_player', 'media', 'player controls, timestamps, playback UI', 'drop'),
136
+ # ---- events / travel
137
+ C('event_card', 'events_travel', 'event name, date, time, venue, price', 'kv', 'name date venue price', 'Event'),
138
+ C('ticket_options', 'events_travel', 'ticket types and prices', 'table', 'rows', heading='Tickets'),
139
+ C('hotel_header', 'events_travel', 'hotel or rental name, rating, price per night, location', 'header', 'name rating price location', 'Hotel LodgingBusiness VacationRental'),
140
+ C('amenities_list', 'events_travel', 'amenities or facilities', 'bullets', 'items', heading='Amenities'),
141
+ C('flight_option', 'events_travel', 'flight times, airline, stops, price', 'kv', 'airline depart arrive stops price', 'Flight'),
142
+ C('itinerary', 'events_travel', 'day-by-day plan or schedule', 'steps', 'items', heading='Itinerary'),
143
+ C('booking_policy', 'events_travel', 'cancellation, check-in or house rules', 'kv', 'rules', heading='Policies'),
144
+ # ---- food / health
145
+ C('recipe', 'food_health', 'recipe title, time, servings, rating', 'header', 'name time servings rating', 'Recipe'),
146
+ C('ingredients', 'food_health', 'list of recipe ingredients', 'bullets', 'items', heading='Ingredients'),
147
+ C('instructions', 'food_health', 'numbered cooking or how-to steps', 'steps', 'items', 'HowTo', 'Steps'),
148
+ C('nutrition_facts', 'food_health', 'calories and nutrients', 'kv', 'nutrients', 'NutritionInformation', 'Nutrition'),
149
+ C('menu', 'food_health', 'restaurant menu items and prices', 'cards', 'items', 'Menu', 'Menu'),
150
+ C('medical_info', 'food_health', 'symptoms, dosage, treatment or health facts', 'prose', 'text', 'MedicalCondition Drug'),
151
+ # ---- data / finance
152
+ C('data_table', 'data_finance', 'table of data with rows and columns', 'table', 'rows'),
153
+ C('stat_highlight', 'data_finance', 'big number or key statistic with label', 'kv', 'label value'),
154
+ C('stock_quote', 'data_finance', 'ticker, price, change, market data', 'kv', 'symbol price change volume market_cap'),
155
+ C('chart', 'data_finance', 'chart or graph with its labels', 'prose', 'title text'),
156
+ C('pricing_plans', 'business', 'subscription plans with prices and features', 'table', 'rows', heading='Pricing'),
157
+ # ---- business
158
+ C('hero', 'business', 'headline and tagline at the top of a landing page', 'header', 'headline tagline'),
159
+ C('feature_grid', 'business', 'grid of product features with short descriptions', 'kv', 'features', heading='Features'),
160
+ C('testimonial', 'business', 'customer quote or case study snippet', 'prose', 'author text'),
161
+ C('logo_wall', 'business', 'customer or partner logos', 'drop'),
162
+ C('faq', 'business', 'frequently asked questions and answers', 'qa', 'items', 'FAQPage Question', 'FAQ'),
163
+ C('call_to_action', 'business', 'sign up, get started, contact sales buttons', 'drop'),
164
+ C('team_section', 'business', 'team members with roles', 'cards', 'items', heading='Team'),
165
+ C('timeline', 'reference', 'dated events in order', 'bullets', 'items', heading='Timeline'),
166
+ # ---- reference
167
+ C('infobox', 'reference', 'summary box of key facts about a subject', 'kv', 'facts', heading='Facts'),
168
+ C('definition', 'reference', 'term and its definition', 'kv', 'term definition'),
169
+ C('encyclopedia_section', 'reference', 'section of encyclopedia text under a heading', 'prose', 'text'),
170
+ C('see_also', 'reference', 'see also list of related topics', 'links', 'items', heading='See also'),
171
+ C('citations', 'reference', 'references, footnotes, sources', 'drop'),
172
+ C('disambiguation', 'reference', 'list of meanings for an ambiguous term', 'bullets', 'items'),
173
+ # ---- chrome
174
+ C('navigation', 'chrome', 'site menus, tabs, category lists', 'drop'),
175
+ C('breadcrumb', 'chrome', 'category path like Home > Electronics > Headphones', 'kv', 'path', 'BreadcrumbList'),
176
+ C('footer', 'chrome', 'site footer, copyright, legal and policy links', 'drop'),
177
+ C('promo_ad', 'chrome', 'ads, sponsored items, credit card or upsell offers', 'drop'),
178
+ C('cookie_banner', 'chrome', 'cookie or consent notice', 'drop'),
179
+ C('login_prompt', 'chrome', 'sign in, create account or app download prompt', 'drop'),
180
+ C('share_buttons', 'chrome', 'share, save, like buttons', 'drop'),
181
+ C('ui_chrome', 'chrome', 'loading text, image placeholders, empty widgets, error text', 'drop'),
182
+ C('recommendations', 'chrome', 'customers also bought, you may also like, trending', 'links', 'items'),
183
+ ]
184
+
185
+ BY_NAME = {c.name: c for c in CATALOG}
186
+ BY_CATEGORY = {cat: [c for c in CATALOG if c.category == cat] for cat in CATEGORIES}
187
+ BY_LD = {t: c for c in CATALOG for t in c.ld}
188
+ assert len(BY_NAME) == len(CATALOG), 'duplicate component name'
189
+ assert all(len(v) <= 255 for v in BY_CATEGORY.values())
givememd/compose.py ADDED
@@ -0,0 +1,337 @@
1
+ """Component pipeline: segment the page finely, label each section with a catalog component, fill, render.
2
+
3
+ segments(root) -> Segment list in reading order (small, single-purpose sections)
4
+ page_type(...) -> one catalog category for the whole page (JSON-LD, else Jev, else rules)
5
+ label(...) -> a component per segment: confident rules first, Jev for the rest
6
+ render(...) -> markdown: components drawn with fixed layouts, junk dropped, same-kind runs merged
7
+
8
+ Turned on with convert(..., mode='components') or GIVEMEMD_COMPONENTS=1. Without a Jev key it still runs, with
9
+ rules-only labels (coarser: anything unclear is kept as generic prose).
10
+ """
11
+ import hashlib, re
12
+ from collections import Counter
13
+ from dataclasses import dataclass, field
14
+
15
+ from . import datatables, jev
16
+ from .components import BY_CATEGORY, BY_LD, BY_NAME, CATEGORIES, CATALOG
17
+
18
+ DROP_CONFIDENCE = float(__import__('os').environ.get('GIVEMEMD_DROP_CONFIDENCE', '0.7')) # a model must be this sure before content is thrown away; otherwise it is kept
19
+ MAXSEG = 700 # a section bigger than this is split into its children
20
+ LEAF = {'table', 'pre', 'ul', 'ol', 'dl'}
21
+ LEAF_MAX = 6000
22
+ HEADING = re.compile(r'^#{1,6} ')
23
+ FACT = re.compile(r'^[-\s]*([$€£¥₹]\s?[\d,.]+|[\d.]+ out of 5|\(?[\d,]+\)? ?(ratings|reviews)|[\d.]+ ?stars?)\b', re.I)
24
+ KV = re.compile(r'^- ?[^:]{1,40}: \S')
25
+ GENERIC = ['article_body', 'doc_prose', 'encyclopedia_section', 'data_table', 'image_caption', 'related_articles',
26
+ 'see_also', 'faq', 'breadcrumb', 'key_takeaways', 'callout_note', 'code_example']
27
+ PROSE_FOR = {'article': 'article_body', 'docs': 'doc_prose', 'reference': 'encyclopedia_section', 'community': 'post_body',
28
+ 'commerce': 'product_description', 'food_health': 'article_body', 'business': 'article_body'}
29
+ KV_FOR = {'commerce': 'spec_table', 'reference': 'infobox', 'docs': 'parameter_list', 'profile': 'contact_info'}
30
+ LD_CATEGORY = {'Product': 'commerce', 'NewsArticle': 'article', 'Article': 'article', 'BlogPosting': 'article',
31
+ 'Recipe': 'food_health', 'Event': 'events_travel', 'JobPosting': 'listings', 'ItemList': 'listings',
32
+ 'QAPage': 'community', 'DiscussionForumPosting': 'community', 'VideoObject': 'media', 'Movie': 'media',
33
+ 'Book': 'media', 'Person': 'profile', 'ProfilePage': 'profile', 'Organization': 'profile',
34
+ 'LocalBusiness': 'profile', 'SoftwareSourceCode': 'docs', 'TechArticle': 'docs', 'FAQPage': 'business',
35
+ 'Hotel': 'events_travel', 'SingleFamilyResidence': 'listings'}
36
+
37
+
38
+ # when a page carries several JSON-LD entities, the main one is the most specific
39
+ LD_PRIORITY = ['Product', 'Recipe', 'Event', 'JobPosting', 'NewsArticle', 'BlogPosting', 'Article', 'TechArticle', 'QAPage',
40
+ 'DiscussionForumPosting', 'Movie', 'Book', 'Hotel', 'SingleFamilyResidence', 'SoftwareSourceCode', 'LocalBusiness',
41
+ 'Person', 'ProfilePage', 'VideoObject', 'FAQPage', 'ItemList', 'Organization']
42
+
43
+
44
+ def _types(o):
45
+ t = o.get('@type')
46
+ return t if isinstance(t, list) else [t]
47
+
48
+
49
+ def main_entities(ld):
50
+ rank = lambda o: min((LD_PRIORITY.index(t) for t in _types(o) if t in LD_PRIORITY), default=len(LD_PRIORITY))
51
+ return sorted(ld, key=rank)
52
+
53
+
54
+ # types that sites attach to almost any page; they don't say what the page is
55
+ WEAK_LD = {'Organization', 'ItemList', 'Person', 'VideoObject', 'Article', 'FAQPage', 'WebPage', 'WebSite', 'BreadcrumbList'}
56
+ PROSE_KINDS = ['article_body', 'subheading_section', 'doc_prose', 'encyclopedia_section', 'post_body', 'answer', 'review_item',
57
+ 'product_description', 'callout_note', 'author_bio', 'testimonial', 'pull_quote', 'image_caption', 'medical_info',
58
+ 'api_signature', 'parameter_list', 'return_value', 'transcript', 'related_articles', 'promo_ad', 'login_prompt', 'ui_chrome']
59
+
60
+
61
+ @dataclass
62
+ class Segment:
63
+ blocks: list # markdown blocks (from extract.MD)
64
+ tag: str = ''
65
+ links: list = field(default_factory=list)
66
+ label: str = ''
67
+ how: str = '' # 'rule:<name>' | 'jev' | 'default'
68
+ confidence: float = 1.0
69
+
70
+ @property
71
+ def text(self):
72
+ return ' '.join(re.sub(r'^#+ |^- ', '', b) for b in self.blocks)
73
+
74
+
75
+ # ---------- segmentation ----------
76
+ def segments(root, url):
77
+ from .extract import MD
78
+ out = []
79
+
80
+ def emit(nodes, tag):
81
+ md = MD(url)
82
+ for n in nodes:
83
+ md.handle(n)
84
+ md.flush()
85
+ if md.blocks:
86
+ out.append(Segment(md.blocks, tag, md.links))
87
+
88
+ def rec(node):
89
+ run = [] # consecutive inline children (text, <span>, <a>...)
90
+ for c in node.iter(include_text=True):
91
+ if c.tag == '-text' or c.tag in ('span', 'a', 'b', 'strong', 'em', 'i', 'code', 'small', 'label', 'sup', 'sub', 'br'):
92
+ run.append(c); continue
93
+ if run:
94
+ emit(run, 'inline'); run = []
95
+ text = c.text(separator=' ')
96
+ n = len(re.sub(r'\s+', ' ', text).strip())
97
+ if n == 0:
98
+ continue
99
+ if c.tag in LEAF and n <= LEAF_MAX or c.tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
100
+ emit([c], c.tag); continue
101
+ if n <= MAXSEG and not _mixed(c, url):
102
+ emit([c], c.tag); continue
103
+ rec(c)
104
+ if run:
105
+ emit(run, 'inline')
106
+
107
+ rec(root)
108
+ return _attach_headings(out)
109
+
110
+
111
+ def _mixed(node, url):
112
+ """A small box that holds a price/rating fact next to other text (Amazon: price + Visa promo) is split."""
113
+ from .extract import MD
114
+ md = MD(url); md.walk(node); md.flush()
115
+ return len(md.blocks) > 1 and any(FACT.match(b) for b in md.blocks) and any(len(b) > 60 for b in md.blocks) \
116
+ and node.css_first('div,span,p,li') is not None
117
+
118
+
119
+ def _attach_headings(segs):
120
+ """A heading on its own belongs to the section that follows it."""
121
+ out, pending = [], []
122
+ for s in segs:
123
+ if all(HEADING.match(b) for b in s.blocks):
124
+ pending += s.blocks; continue
125
+ if pending:
126
+ if len(pending) >= 3: # a stack of empty headings (tab labels, "step 1..11") says nothing
127
+ pending = pending[-1:]
128
+ s.blocks = pending + s.blocks; pending = []
129
+ out.append(s)
130
+ if pending:
131
+ out.append(Segment(pending, 'h'))
132
+ for s in out: # the same rule inside one section
133
+ blocks, run = [], []
134
+ for b in s.blocks + ['']:
135
+ if b and HEADING.match(b):
136
+ run.append(b); continue
137
+ blocks += run[-1:] if len(run) >= 3 else run
138
+ run = []
139
+ if b: blocks.append(b)
140
+ s.blocks = blocks
141
+ return out
142
+
143
+
144
+ # ---------- labeling ----------
145
+ def page_type(meta, ld, segs, tables, judge):
146
+ counts = Counter(t for o in ld for t in _types(o) if t)
147
+ repeated = [t for t, n in counts.items() if n >= 3 and t not in ('ListItem', 'BreadcrumbList', 'Question', 'Answer')]
148
+ if repeated: # many Events / Products / homes on one page = a listing
149
+ return 'listings', f'ld:{repeated[0]}x{counts[repeated[0]]}'
150
+ for o in main_entities(ld):
151
+ for t in _types(o):
152
+ if t in LD_CATEGORY and t not in WEAK_LD:
153
+ return LD_CATEGORY[t], f'ld:{t}'
154
+ if judge:
155
+ state = f"Web page: {meta.get('title', '')}\nURL: {meta.get('url', '')}\nOpening text: " + ' '.join(s.text for s in segs)[:1500]
156
+ a = judge.ask(state, {'page': {'type': 'choice', 'instructions': 'What kind of web page is this?',
157
+ 'criteria': {k: v for k, v in CATEGORIES.items() if k != 'chrome'}}})
158
+ if a: return a['page']['choice'], 'jev'
159
+ if tables and len(tables[0].recs) >= 8: return 'listings', 'rule:table'
160
+ if sum(1 for s in segs if s.tag == 'pre') >= 3: return 'docs', 'rule:code'
161
+ return 'article', 'default'
162
+
163
+
164
+ def _rule(s, ptype):
165
+ """Confident labels that need no model. Returns a component name or None."""
166
+ from .extract import UI_TEXT, DATA_MARK
167
+ body = [b for b in s.blocks if not HEADING.match(b)]
168
+ if any(DATA_MARK in b for b in s.blocks): return 'listing_grid'
169
+ if not body: return None
170
+ if all(UI_TEXT.match(b) for b in body): return 'ui_chrome'
171
+ if s.tag == 'pre' or all(b.startswith('```') for b in body): return 'code_example'
172
+ if sum(1 for b in body if KV.match(b)) >= max(2, 0.7 * len(body)): return KV_FOR.get(ptype, 'spec_table')
173
+ text = ' '.join(body)
174
+ link_chars = sum(len(t) for t, _ in s.links)
175
+ if len(text) >= 250 and link_chars < 0.25 * len(text) and not any(FACT.match(b) for b in body):
176
+ return 'prose?' # prose for sure; which kind is asked (cheaply) below
177
+ return None
178
+
179
+
180
+ def options(ptype):
181
+ names = [c.name for c in BY_CATEGORY.get(ptype, [])] + GENERIC + [c.name for c in BY_CATEGORY['chrome']]
182
+ return {n: BY_NAME[n].desc for n in dict.fromkeys(names)}
183
+
184
+
185
+ def label(segs, ptype, meta, judge):
186
+ ask, prose = [], []
187
+ for s in segs:
188
+ r = _rule(s, ptype)
189
+ if r == 'prose?':
190
+ s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'rule'
191
+ prose.append(s)
192
+ elif r:
193
+ s.label, s.how = r, 'rule'
194
+ else:
195
+ ask.append(s)
196
+ if judge and (ask or prose):
197
+ crit, pcrit = options(ptype), {n: BY_NAME[n].desc for n in PROSE_KINDS}
198
+ state = f"Web page ({ptype}): {meta.get('title', '')}\nURL: {meta.get('url', '')}"
199
+ q = lambda s, c: {'type': 'choice', 'criteria': c, 'instructions': 'Which component is this section of the page?\nSection:\n' + s.text[:600]}
200
+ qs = {f's{i}': q(s, crit) for i, s in enumerate(ask)}
201
+ qs.update({f'p{i}': q(s, pcrit) for i, s in enumerate(prose)})
202
+ a = judge.ask(state, qs)
203
+ if a:
204
+ for i, s in enumerate(prose):
205
+ s.label, s.how, s.confidence = a[f'p{i}']['choice'], 'jev-prose', a[f'p{i}'].get('confidence', 0.0)
206
+ if BY_NAME[s.label].shape == 'drop' and s.confidence < DROP_CONFIDENCE:
207
+ s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'kept'
208
+ for i, s in enumerate(ask):
209
+ s.label, s.how, s.confidence = a[f's{i}']['choice'], 'jev', a[f's{i}'].get('confidence', 0.0)
210
+ if BY_NAME[s.label].shape == 'drop' and s.confidence < DROP_CONFIDENCE:
211
+ s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), f'kept (jev said {s.label} {s.confidence:.2f})'
212
+ for s in ask:
213
+ if not s.label: # no model: keep it, as generic content
214
+ s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'default'
215
+ return segs
216
+
217
+
218
+ # ---------- filling + rendering ----------
219
+ ALIASES = {'name': ['name', 'headline', 'title'], 'headline': ['headline', 'name'], 'title': ['name', 'headline', 'title'],
220
+ 'price': ['offers.price', 'offers.lowPrice', 'price'], 'brand': ['brand.name', 'brand'],
221
+ 'rating': ['aggregateRating.ratingValue'], 'rating_count': ['aggregateRating.reviewCount', 'aggregateRating.ratingCount'],
222
+ 'author': ['author.name', 'author'], 'date': ['datePublished', 'startDate', 'uploadDate'],
223
+ 'availability': ['offers.availability'], 'standfirst': ['description'], 'description': ['description'],
224
+ 'time': ['totalTime'], 'servings': ['recipeYield'], 'venue': ['location.name'], 'channel': ['author.name'],
225
+ 'views': ['interactionStatistic.userInteractionCount'], 'followers': ['interactionStatistic.userInteractionCount']}
226
+
227
+
228
+ def ld_header(ld, ptype):
229
+ """The page's main entity from JSON-LD, filled into its header component."""
230
+ for o in main_entities(ld):
231
+ types = _types(o)
232
+ comp = next((BY_LD[t] for t in types if t in BY_LD and BY_LD[t].shape == 'header'), None)
233
+ if not comp: continue
234
+ flat = datatables.flatten(o)
235
+ vals = {}
236
+ for f in comp.fields:
237
+ for k in ALIASES.get(f, [f]):
238
+ v = flat.get(k)
239
+ if v not in (None, '') and not str(v).startswith('http'):
240
+ vals[f] = re.sub(r'^https?://schema.org/', '', str(v)); break
241
+ if vals.get('name') or vals.get('headline') or vals.get('title'):
242
+ return comp, vals
243
+ return None, {}
244
+
245
+
246
+ def render_header(comp, vals):
247
+ title = vals.get('name') or vals.get('headline') or vals.get('title')
248
+ facts = [f'**{k.replace("_", " ").title()}:** {v}' for k, v in vals.items() if k not in ('name', 'headline', 'title', 'description', 'standfirst')]
249
+ out = [f'# {title}']
250
+ if facts: out.append(' · '.join(facts))
251
+ if vals.get('standfirst') or vals.get('description'): out.append(vals.get('standfirst') or vals.get('description'))
252
+ return out
253
+
254
+
255
+ STEP_HEADING = re.compile(r'^#+ (step|stage|part)?\s*\d+\.?$', re.I)
256
+
257
+
258
+ def render_segment(s, comp, prev):
259
+ """Blocks keep their page order; only the section's leading headings are treated as its title."""
260
+ lead = 0
261
+ while lead < len(s.blocks) and HEADING.match(s.blocks[lead]):
262
+ lead += 1
263
+ out, rest = list(s.blocks[:lead]), s.blocks[lead:]
264
+ if not out and comp.heading and prev != comp.name:
265
+ out.append(f'## {comp.heading}')
266
+ if comp.shape == 'header':
267
+ short = [b for b in rest if len(b) <= 80 and not HEADING.match(b)]
268
+ out += ([' · '.join(re.sub(r'^- ', '', b) for b in short)] if short else []) + [b for b in rest if b not in short]
269
+ elif comp.shape in ('bullets', 'links'):
270
+ out += [b if b.startswith(('- ', '|', '```', '#')) else f'- {b}' for b in rest[:15 if comp.shape == 'links' else None]]
271
+ elif comp.shape == 'steps': # "step 1".."step 11" headings carry nothing once the steps are numbered
272
+ out = [b for b in out if not STEP_HEADING.match(b)]
273
+ items = [re.sub(r'^- ', '', b) for b in rest if not STEP_HEADING.match(b)]
274
+ out += [b if HEADING.match(b) else f'{i}. {b}' for i, b in enumerate(items, 1)]
275
+ else: # kv, prose, qa, table, code: the blocks already have the right form
276
+ out += rest
277
+ return out
278
+
279
+
280
+ # short facts that belong in the main header's facts line, keyed by the field they fill
281
+ FACT_FIELD = {'price_offer': 'Price', 'review_summary': 'Rating', 'stock_status': 'Availability', 'shipping_returns': 'Shipping',
282
+ 'variant_picker': 'Options', 'seller_info': 'Seller', 'result_count': 'Results', 'stat_highlight': 'Stats',
283
+ 'contact_info': 'Contact', 'opening_hours': 'Hours', 'genre_tags': 'Genres', 'breadcrumb': 'Category'}
284
+ NEAR = 8 # how far from the header a fact may sit
285
+
286
+
287
+ def header_facts(segs):
288
+ """Fill the main header component: index of the header section, and the nearby short fact sections that
289
+ fill its fields (price, rating, availability...)."""
290
+ h = next((i for i, s in enumerate(segs) if BY_NAME.get(s.label) and BY_NAME[s.label].shape == 'header'), None)
291
+ if h is None: return None, {}
292
+ facts = {}
293
+ for i in range(max(0, h - NEAR), min(len(segs), h + NEAR + 1)):
294
+ s = segs[i]
295
+ if s.label in FACT_FIELD and len(s.text) <= 120:
296
+ facts[i] = (FACT_FIELD[s.label], re.sub(r'^#+ ', '', s.text))
297
+ return h, facts
298
+
299
+
300
+ def render(segs, ptype, meta, ld, cands):
301
+ from .extract import DATA_MARK
302
+ lines, prev, dropped = [], None, Counter()
303
+ comp, vals = ld_header(ld, ptype)
304
+ header_text = ''
305
+ if comp:
306
+ h = render_header(comp, vals)
307
+ lines += h; header_text = ' '.join(h).lower(); prev = comp.name
308
+ tables = [datatables.render([c]) for c in cands]
309
+ h, facts = header_facts(segs)
310
+ for i, s in enumerate(segs):
311
+ c = BY_NAME.get(s.label) or BY_NAME[PROSE_FOR.get(ptype, 'article_body')]
312
+ if c.shape == 'drop':
313
+ dropped[c.name] += 1; continue
314
+ if i in facts: continue # rendered inside the header's facts line
315
+ if i == h and facts:
316
+ block = render_segment(s, c, prev)
317
+ by_field = {}
318
+ for f, v in facts.values(): by_field.setdefault(f, []).append(v)
319
+ block.insert(1 if block and HEADING.match(block[0]) else 0,
320
+ ' · '.join(f'**{f}:** ' + '; '.join(dict.fromkeys(v)) for f, v in by_field.items()))
321
+ if comp and c.name == comp.name:
322
+ block = [b for b in block if b.lower().lstrip('# ') not in header_text]
323
+ lines += block; prev = c.name; continue
324
+ if any(DATA_MARK in b for b in s.blocks) and tables:
325
+ lines += tables.pop(0); prev = 'listing_grid'; continue
326
+ block = render_segment(s, c, prev)
327
+ if comp and c.name == comp.name: # DOM copy of the JSON-LD header: keep only what it adds
328
+ block = [b for b in block if b.lower().lstrip('# ') not in header_text]
329
+ lines += block
330
+ prev = c.name
331
+ for t in tables:
332
+ lines += t
333
+ return lines, dropped
334
+
335
+
336
+ def signature(s):
337
+ return hashlib.sha1(s.text[:200].encode()).hexdigest()[:12]