{
  "generated": "2026-09-03 21:33:17",
  "files": [
    {
      "path": "bitacora/background/service-worker.js",
      "url": "codigo/arquivos/bitacora/background/service-worker.js",
      "downloadName": "service-worker.js",
      "size": 20127,
      "mime": "text/javascript",
      "text": "/*\n * Archivo: service-worker.js\n * Autores: Artai Bringas y Javier Rodríguez\n * Fecha: 26 de mayo del 2026\n * Función: Proceso en segundo plano que captura cada visita web, registra el tiempo de permanencia en cada página, aplica etiquetas automáticamente y escribe los datos en el CSV activo, incluso con el panel cerrado.\n * Licencia: MIT\n */\n\n'use strict';\nasync function tabStarted(tabId, visitedAt) {\n  try {\n    var data = await chrome.storage.session.get('tabTimes');\n    var map  = data.tabTimes || {};\n    map[String(tabId)] = { visitedAt: visitedAt, startMs: Date.now() };\n    await chrome.storage.session.set({ tabTimes: map });\n  } catch (_) {}\n}\nasync function tabLeft(tabId) {\n  try {\n    var data  = await chrome.storage.session.get('tabTimes');\n    var map   = data.tabTimes || {};\n    var entry = map[String(tabId)];\n    if (!entry) return;\n    var now      = Date.now();\n    var duration = now - entry.startMs;\n    var leftAt   = new Date(now).toISOString();\n    delete map[String(tabId)];\n    await chrome.storage.session.set({ tabTimes: map });\n    var dur  = await chrome.storage.local.get('pendingDurations');\n    var list = dur.pendingDurations || [];\n    list.push({ tab_id: tabId, visited_at: entry.visitedAt, time_on_page_ms: duration, left_at: leftAt });\n    await chrome.storage.local.set({ pendingDurations: list });\n    var dashOpen = await isDashboardOpen();\n    if (!dashOpen) await applyDurationToFile(tabId, entry.visitedAt, duration, leftAt);\n  } catch (e) { console.error('[WML] tabLeft error:', e); }\n}\nfunction parseCSVRow(line) {\n  var cells = [];\n  var cur = '';\n  var inQuote = false;\n  for (var ci = 0; ci < line.length; ci++) {\n    var ch = line[ci];\n    if (inQuote) {\n      if (ch === '\"' && line[ci+1] === '\"') { cur += '\"'; ci++; }\n      else if (ch === '\"') { inQuote = false; }\n      else { cur += ch; }\n    } else {\n      if (ch === '\"') { inQuote = true; }\n      else if (ch === ',') { cells.push(cur); cur = ''; }\n      else { cur += ch; }\n    }\n  }\n  cells.push(cur);\n  return cells;\n}\nasync function applyDurationToFile(tabId, visitedAt, duration, leftAt) {\n  try {\n    var fileHandle = await idbGet('activeFileHandle');\n    if (!fileHandle) return;\n    var perm = await fileHandle.queryPermission({ mode: 'readwrite' });\n    if (perm !== 'granted') return;\n    var file    = await fileHandle.getFile();\n    var content = await file.text();\n    var lines   = content.split('\\n');\n    var headerIdx = -1;\n    for (var h = 0; h < lines.length; h++) {\n      if (!lines[h].startsWith('#') && lines[h].trim()) { headerIdx = h; break; }\n    }\n    if (headerIdx < 0) return;\n    var headers     = parseCSVRow(lines[headerIdx]);\n    var tabIdCol    = headers.indexOf('tab_id');\n    var visitedCol  = headers.indexOf('visited_at');\n    var durationCol = headers.indexOf('time_on_page_ms');\n    var leftAtCol   = headers.indexOf('left_at');\n    if (tabIdCol < 0 || visitedCol < 0) return;\n    var updated = false;\n    for (var i = headerIdx + 1; i < lines.length; i++) {\n      if (!lines[i].trim()) continue;\n      var cells      = parseCSVRow(lines[i]);\n      var rowTabId   = (cells[tabIdCol]   || '').replace(/^\"|\"$/g,'').trim();\n      var rowVisited = (cells[visitedCol] || '').replace(/^\"|\"$/g,'').trim();\n      if (rowTabId === String(tabId) && rowVisited.startsWith(visitedAt.slice(0,19))) {\n        if (durationCol >= 0) cells[durationCol] = String(duration);\n        if (leftAtCol   >= 0) cells[leftAtCol]   = csvEscape(leftAt);\n        lines[i] = cells.map(csvEscape).join(',');\n        updated = true;\n        break;\n      }\n    }\n    if (!updated) return;\n    var writable = await fileHandle.createWritable();\n    await writable.write(lines.join('\\n'));\n    await writable.close();\n  } catch (e) { console.error('[WML] applyDurationToFile error:', e); }\n}\nchrome.tabs.onActivated.addListener(async function(activeInfo) {\n  try {\n    var data = await chrome.storage.session.get('tabTimes');\n    var map  = data.tabTimes || {};\n    for (var tid in map) {\n      if (parseInt(tid) !== activeInfo.tabId) await tabLeft(parseInt(tid));\n    }\n    if (map[String(activeInfo.tabId)]) {\n      map[String(activeInfo.tabId)].startMs = Date.now();\n      await chrome.storage.session.set({ tabTimes: map });\n    }\n  } catch (_) {}\n});\nchrome.tabs.onUpdated.addListener(async function(tabId, changeInfo) {\n  if (changeInfo.url) await tabLeft(tabId);\n});\nchrome.tabs.onRemoved.addListener(async function(tabId) {\n  await tabLeft(tabId);\n});\nchrome.runtime.onInstalled.addListener((details) => {\n  if (details.reason === 'install') {\n    chrome.tabs.create({ url: chrome.runtime.getURL('dashboard/index.html') });\n  }\n});\nconst FIELD_NAMES = ['url','title','domain','visited_at','time_on_page_ms','user_tags','user_note','active','window_height_px','viewport_height_px','screen_height_px','tab_height_px','page_total_height_px','window_width_px','viewport_width_px','screen_width_px','tab_width_px','pinned','article_author','transition_qualifiers','charset','theme_color','frozen','cookies_enabled','auto_discardable','discarded','og_description','dom_complete_ms','dom_interactive_ms','dom_content_loaded_end_ms','dom_content_loaded_start_ms','is_reader_mode_eligible','is_online','external_link_count','link_count','status','window_state','muted_extension_id','fav_icon_url','left_at','load_event_end_ms','connect_end_ms','response_end_ms','unload_event_end_ms','domain_lookup_end_ms','form_count','meta_generator','frame_id','group_id','tab_id','process_id','session_id','window_id','parent_frame_id','opener_tab_id','lang','browser_language','browser_languages','iframe_count','og_image','image_count','incognito','load_event_start_ms','connect_start_ms','response_start_ms','request_start_ms','unload_event_start_ms','domain_lookup_start_ms','worker_start_ms','secure_connection_start_ms','device_memory_gb','meta_description','meta_robots','article_modified_time','muted_reason','do_not_track','og_site_name','hardware_concurrency','word_count','tab_index','window_left_px','window_top_px','next_hop_protocol','article_published_time','device_pixel_ratio','redirect_count','referrer','audible','highlighted','connection_rtt_ms','script_count','article_section','muted','encoded_body_size_bytes','decoded_body_size_bytes','transfer_size_bytes','has_json_ld','has_rss_feed','nav_timestamp_ms','nav_type','connection_effective_type','transition_type','window_type','og_type','json_ld_types','og_title','twitter_card','twitter_creator','last_modified','last_accessed_ms','canonical_url','pending_url','connection_downlink_mbps','window_focused','window_always_on_top'];;\nconst IGNORED_PREFIXES = [\n  'chrome://', 'chrome-extension://', 'edge://', 'brave://',\n  'about:', 'data:', 'javascript:', 'file://'\n];\nconst SESSION_TIMEOUT_MS = 30 * 60 * 1000;\nconst MAX_PENDING = 2000;\nconst DASHBOARD_URL = 'dashboard/index.html';\nlet _idb = null;\nfunction openIDB() {\n  if (_idb) return Promise.resolve(_idb);\n  return new Promise((resolve, reject) => {\n    const req = indexedDB.open('wml', 1);\n    req.onupgradeneeded = e => e.target.result.createObjectStore('handles');\n    req.onsuccess = e => { _idb = e.target.result; resolve(_idb); };\n    req.onerror   = e => reject(e.target.error);\n  });\n}\nasync function idbGet(key) {\n  const db = await openIDB();\n  return new Promise((resolve, reject) => {\n    const req = db.transaction('handles', 'readonly').objectStore('handles').get(key);\n    req.onsuccess = e => resolve(e.target.result);\n    req.onerror   = e => reject(e.target.error);\n  });\n}\nfunction csvEscape(v) {\n  if (v === null || v === undefined) return '';\n  const s = String(v);\n  if (s.includes(',') || s.includes('\"') || s.includes('\\n') || s.includes('\\r')) {\n    return '\"' + s.replace(/\"/g, '\"\"') + '\"';\n  }\n  return s;\n}\nfunction recordToCsvRow(record, columns) {\n  return columns.map(col => csvEscape(record[col])).join(',');\n}\nfunction columnsFromCSV(content) {\n  for (const line of content.split('\\n')) {\n    const t = line.trim();\n    if (t && !t.startsWith('#')) return t.split(',').map(c => c.trim());\n  }\n  return FIELD_NAMES;\n}\n\nasync function isDashboardOpen() {\n  try {\n    const tabs = await chrome.tabs.query({\n      url: chrome.runtime.getURL(DASHBOARD_URL)\n    });\n    return tabs.length > 0;\n  } catch (_) { return false; }\n}\n\nasync function writeDirectToFile(record) {\n  try {\n    const fileHandle = await idbGet('activeFileHandle');\n    if (!fileHandle) return false;\n    const perm = await fileHandle.queryPermission({ mode: 'readwrite' });\n    if (perm !== 'granted') return false;\n    const file    = await fileHandle.getFile();\n    const content = await file.text();\n    const columns = columnsFromCSV(content);\n    const newRow  = recordToCsvRow(record, columns);\n    const writable = await fileHandle.createWritable();\n    await writable.write(content.trimEnd() + '\\n' + newRow);\n    await writable.close();\n    return true;\n  } catch (e) {\n    console.error('[WML] writeDirectToFile error:', e);\n    return false;\n  }\n}\n\nasync function saveRecord(record) {\n\n  try {\n    const s = await chrome.storage.local.get('wmlIncognito');\n    if (s.wmlIncognito) return;\n  } catch (_) {}\n  const dashOpen = await isDashboardOpen();\n  if (dashOpen) {\n\n    await appendToPending(record);\n    return;\n  }\n\n  const written = await writeDirectToFile(record);\n  if (!written) await appendToPending(record);\n}\nasync function appendToPending(record) {\n  try {\n    const data = await chrome.storage.local.get('pendingRecords');\n    const pending = data.pendingRecords || [];\n    pending.push(record);\n    if (pending.length > MAX_PENDING) pending.splice(0, pending.length - MAX_PENDING);\n    await chrome.storage.local.set({ pendingRecords: pending });\n  } catch (e) { console.error('[WML] appendToPending error:', e); }\n}\n\nconst transitionMap = new Map();\nchrome.webNavigation.onCommitted.addListener((details) => {\n  if (details.frameId !== 0) return;\n  transitionMap.set(details.tabId, {\n    type:          details.transitionType || '',\n    qualifiers:    (details.transitionQualifiers || []).join(','),\n    parentFrameId: typeof details.parentFrameId === 'number' ? details.parentFrameId : -1,\n    processId:     typeof details.processId     === 'number' ? details.processId     : -1\n  });\n});\nchrome.webNavigation.onCompleted.addListener(async (details) => {\n  if (details.frameId !== 0) return;\n  const { url, tabId, timeStamp } = details;\n  if (IGNORED_PREFIXES.some(p => url.startsWith(p))) return;\n  const transition = transitionMap.get(tabId) || { type:'', qualifiers:'', parentFrameId:-1, processId:-1 };\n  transitionMap.delete(tabId);\n  let tab;\n  try { tab = await chrome.tabs.get(tabId); } catch (_) { return; }\n  let win = {};\n  try { win = await chrome.windows.get(tab.windowId); } catch (_) {}\n  let domain = '';\n  try { domain = new URL(url).hostname; } catch (_) {}\n  const sessionId   = await getSessionId();\n  const contentData = await getContentData(tabId);\n  const mutedInfo   = tab.mutedInfo || {};\n  const record = {\n    url, domain,\n    title:                    tab.title || '',\n    visited_at:               new Date(timeStamp).toISOString(),\n    session_id:               sessionId,\n    left_at:                  null,\n    time_on_page_ms:          null,\n    user_note: '', user_tags: '',\n    active:                   tab.active         ? 1 : 0,\n    article_author:           contentData.article_author           || '',\n    article_modified_time:    contentData.article_modified_time    || '',\n    article_published_time:   contentData.article_published_time   || '',\n    article_section:          contentData.article_section          || '',\n    audible:                  tab.audible        ? 1 : 0,\n    auto_discardable:         tab.autoDiscardable? 1 : 0,\n    browser_language:         contentData.browser_language         || '',\n    browser_languages:        contentData.browser_languages        || '',\n    canonical_url:            contentData.canonical_url            || '',\n    charset:                  contentData.charset                  || '',\n    connect_end_ms:           contentData.connect_end_ms,\n    connect_start_ms:         contentData.connect_start_ms,\n    connection_downlink_mbps: contentData.connection_downlink_mbps,\n    connection_effective_type:contentData.connection_effective_type|| '',\n    connection_rtt_ms:        contentData.connection_rtt_ms,\n    cookies_enabled:          contentData.cookies_enabled,\n    decoded_body_size_bytes:  contentData.decoded_body_size_bytes,\n    device_memory_gb:         contentData.device_memory_gb,\n    device_pixel_ratio:       contentData.device_pixel_ratio,\n    discarded:                tab.discarded      ? 1 : 0,\n    do_not_track:             contentData.do_not_track,\n    dom_complete_ms:          contentData.dom_complete_ms,\n    dom_content_loaded_end_ms:   contentData.dom_content_loaded_end_ms,\n    dom_content_loaded_start_ms: contentData.dom_content_loaded_start_ms,\n    dom_interactive_ms:       contentData.dom_interactive_ms,\n    domain_lookup_end_ms:     contentData.domain_lookup_end_ms,\n    domain_lookup_start_ms:   contentData.domain_lookup_start_ms,\n    encoded_body_size_bytes:  contentData.encoded_body_size_bytes,\n    external_link_count:      contentData.external_link_count,\n    fav_icon_url:             tab.favIconUrl     || '',\n    form_count:               contentData.form_count,\n    frame_id:                 details.frameId    || 0,\n    frozen:                   tab.frozen         ? 1 : 0,\n    group_id:                 typeof tab.groupId === 'number' ? tab.groupId : -1,\n    hardware_concurrency:     contentData.hardware_concurrency,\n    has_json_ld:              contentData.has_json_ld,\n    has_rss_feed:             contentData.has_rss_feed,\n    highlighted:              tab.highlighted    ? 1 : 0,\n    iframe_count:             contentData.iframe_count,\n    image_count:              contentData.image_count,\n    incognito:                tab.incognito      ? 1 : 0,\n    is_online:                contentData.is_online,\n    is_reader_mode_eligible:  contentData.is_reader_mode_eligible,\n    json_ld_types:            contentData.json_ld_types            || '',\n    lang:                     contentData.lang                     || '',\n    last_accessed_ms:         typeof tab.lastAccessed === 'number' ? Math.round(tab.lastAccessed) : 0,\n    last_modified:            contentData.last_modified            || '',\n    link_count:               contentData.link_count,\n    load_event_end_ms:        contentData.load_event_end_ms,\n    load_event_start_ms:      contentData.load_event_start_ms,\n    meta_description:         contentData.meta_description         || '',\n    meta_generator:           contentData.meta_generator           || '',\n    meta_robots:              contentData.meta_robots              || '',\n    muted:                    mutedInfo.muted    ? 1 : 0,\n    muted_extension_id:       mutedInfo.extensionId                || '',\n    muted_reason:             mutedInfo.reason                     || '',\n    nav_timestamp_ms:         Math.round(timeStamp),\n    nav_type:                 contentData.nav_type                 || '',\n    next_hop_protocol:        contentData.next_hop_protocol        || '',\n    og_description:           contentData.og_description           || '',\n    og_image:                 contentData.og_image                 || '',\n    og_site_name:             contentData.og_site_name             || '',\n    og_title:                 contentData.og_title                 || '',\n    og_type:                  contentData.og_type                  || '',\n    opener_tab_id:            typeof tab.openerTabId === 'number' ? tab.openerTabId : -1,\n    page_total_height_px:     contentData.page_total_height_px,\n    parent_frame_id:          transition.parentFrameId,\n    pending_url:              tab.pendingUrl     || '',\n    pinned:                   tab.pinned         ? 1 : 0,\n    process_id:               transition.processId,\n    redirect_count:           contentData.redirect_count,\n    referrer:                 contentData.referrer                 || '',\n    request_start_ms:         contentData.request_start_ms,\n    response_end_ms:          contentData.response_end_ms,\n    response_start_ms:        contentData.response_start_ms,\n    screen_height_px:         contentData.screen_height_px,\n    screen_width_px:          contentData.screen_width_px,\n    script_count:             contentData.script_count,\n    secure_connection_start_ms: contentData.secure_connection_start_ms,\n    status:                   tab.status         || '',\n    tab_height_px:            contentData.tab_height_px,\n    tab_id:                   tabId,\n    tab_index:                tab.index,\n    tab_width_px:             contentData.tab_width_px,\n    theme_color:              contentData.theme_color              || '',\n    transfer_size_bytes:      contentData.transfer_size_bytes,\n    transition_qualifiers:    transition.qualifiers,\n    transition_type:          transition.type,\n    twitter_card:             contentData.twitter_card             || '',\n    twitter_creator:          contentData.twitter_creator          || '',\n    unload_event_end_ms:      contentData.unload_event_end_ms,\n    unload_event_start_ms:    contentData.unload_event_start_ms,\n    viewport_height_px:       contentData.viewport_height_px,\n    viewport_width_px:        contentData.viewport_width_px,\n    window_always_on_top:     win.alwaysOnTop    ? 1 : 0,\n    window_focused:           win.focused        ? 1 : 0,\n    window_height_px:         typeof win.height  === 'number' ? win.height : 0,\n    window_id:                typeof win.id      === 'number' ? win.id     : -1,\n    window_left_px:           typeof win.left    === 'number' ? win.left   : 0,\n    window_state:             win.state          || '',\n    window_top_px:            typeof win.top     === 'number' ? win.top    : 0,\n    window_type:              win.type           || '',\n    window_width_px:          typeof win.width   === 'number' ? win.width  : 0,\n    word_count:               contentData.word_count,\n    worker_start_ms:          contentData.worker_start_ms\n  };\n\n  try {\n    var autoTagData = await chrome.storage.local.get(['wmlAutoTag', 'wmlTagLists']);\n    if (autoTagData.wmlAutoTag !== false) {\n      var tagLists = autoTagData.wmlTagLists || [];\n      var tags = [];\n      tagLists.forEach(function(list) {\n        if ((list.entries || []).some(function(e) {\n        if (e.type === 'domain') { var bare = e.value.replace(/^https?:\\/\\//, '').replace(/^www\\./, '').split('/')[0].split('?')[0].toLowerCase(); var d = domain.replace(/^www\\./, '').toLowerCase(); return d === bare; }\n          if (e.type === 'pattern') { var pat = e.value.replace(/^\\*\\./, ''); return domain === pat || domain.endsWith('.' + pat); }\n          return false;\n        })) tags.push(list.name);\n      });\n      if (tags.length) record.user_tags = tags.join(', ');\n    }\n  } catch(_) {}\n  await saveRecord(record);\n  await tabStarted(tabId, record.visited_at);\n});\n\nasync function getContentData(tabId) {\n  return new Promise((resolve) => {\n    const timer = setTimeout(() => resolve({}), 3000);\n    try {\n      chrome.tabs.sendMessage(tabId, { type: 'GET_METADATA' }, (response) => {\n        clearTimeout(timer);\n        if (chrome.runtime.lastError || !response) resolve({});\n        else resolve(response);\n      });\n    } catch (_) { clearTimeout(timer); resolve({}); }\n  });\n}\n\nasync function getSessionId() {\n  try {\n    const now     = Date.now();\n    const storage = chrome.storage.session || chrome.storage.local;\n    const key     = chrome.storage.session ? 'sessionId'     : '__wml_sessionId';\n    const actKey  = chrome.storage.session ? 'lastActivity'  : '__wml_lastActivity';\n    const data    = await storage.get([key, actKey]);\n    if (!data[key] || (now - (data[actKey] || 0)) > SESSION_TIMEOUT_MS) {\n      const newId = crypto.randomUUID();\n      await storage.set({ [key]: newId, [actKey]: now });\n      return newId;\n    }\n    await storage.set({ [actKey]: now });\n    return data[key];\n  } catch (_) { return 'fallback-' + Date.now(); }\n}\n"
    },
    {
      "path": "bitacora/content/content.js",
      "url": "codigo/arquivos/bitacora/content/content.js",
      "downloadName": "content.js",
      "size": 7104,
      "mime": "text/javascript",
      "text": "/*\n * Archivo: content.js\n * Autores: Artai Bringas y Javier Rodríguez\n * Fecha: 26 de mayo del 2026\n * Función: Script inyectado en cada página visitada que extrae los metadatos del DOM y de las API del navegador (rendimiento, red, viewport, Open Graph, JSON-LD, etc.) y los envía al service worker.\n * Licencia: MIT\n */\n\n'use strict';\nchrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n  if (message.type !== 'GET_METADATA') return true;\n  try { sendResponse(extractMetadata()); }\n  catch (_) { sendResponse({}); }\n  return true;\n});\nfunction extractMetadata() {\n  const nav  = performance.getEntriesByType('navigation')[0] || {};\n  const conn = (navigator.connection || navigator.mozConnection || navigator.webkitConnection) || {};\n  function getMeta(selectors) {\n    for (const sel of selectors) {\n      try {\n        const el = document.querySelector(sel);\n        if (el && el.content && el.content.trim()) return el.content.trim();\n      } catch (_) {}\n    }\n    return '';\n  }\n  function getLinkHref(sel) {\n    try { const el = document.querySelector(sel); return el ? (el.href || '') : ''; } catch (_) { return ''; }\n  }\n  function numOrNull(v) {\n    return (typeof v === 'number' && isFinite(v)) ? v : null;\n  }\n  let wordCount = 0;\n  try {\n    const t = document.body?.innerText || '';\n    wordCount = t.trim() ? t.trim().split(/\\s+/).length : 0;\n  } catch (_) {}\n  let externalLinks = 0;\n  try {\n    const host = window.location.hostname;\n    for (const a of document.links) {\n      try { if (new URL(a.href).hostname !== host) externalLinks++; } catch (_) {}\n    }\n  } catch (_) {}\n  let hasJsonLd = 0, jsonLdTypes = '';\n  try {\n    const scripts = Array.from(document.querySelectorAll('script[type=\"application/ld+json\"]'));\n    hasJsonLd = scripts.length > 0 ? 1 : 0;\n    const types = [];\n    scripts.forEach(s => {\n      try {\n        const d = JSON.parse(s.textContent);\n        if (d['@type']) types.push(d['@type']);\n        else if (Array.isArray(d['@graph'])) d['@graph'].forEach(i => { if (i['@type']) types.push(i['@type']); });\n      } catch (_) {}\n    });\n    jsonLdTypes = types.join(',');\n  } catch (_) {}\n  let hasRss = 0;\n  try {\n    hasRss = document.querySelector('link[rel=\"alternate\"][type=\"application/rss+xml\"], link[rel=\"alternate\"][type=\"application/atom+xml\"]') ? 1 : 0;\n  } catch (_) {}\n  let readerEligible = 0;\n  try {\n    readerEligible = (document.querySelector('article') && wordCount > 300) ? 1 : 0;\n  } catch (_) {}\n  let dnt = null;\n  try {\n    const v = navigator.doNotTrack;\n    if (v === '1') dnt = 1;\n    else if (v === '0') dnt = 0;\n  } catch (_) {}\n  return {\n    article_author:           getMeta(['meta[name=\"author\"]', 'meta[property=\"article:author\"]']),\n    article_modified_time:    getMeta(['meta[property=\"article:modified_time\"]']),\n    article_published_time:   getMeta(['meta[property=\"article:published_time\"]']),\n    article_section:          getMeta(['meta[property=\"article:section\"]']),\n    browser_language:         navigator.language || '',\n    browser_languages:        (navigator.languages || []).join(','),\n    canonical_url:            getLinkHref('link[rel=\"canonical\"]'),\n    charset:                  document.characterSet || '',\n    connect_end_ms:           numOrNull(nav.connectEnd),\n    connect_start_ms:         numOrNull(nav.connectStart),\n    connection_downlink_mbps: numOrNull(conn.downlink),\n    connection_effective_type: conn.effectiveType || '',\n    connection_rtt_ms:        numOrNull(conn.rtt),\n    cookies_enabled:          navigator.cookieEnabled ? 1 : 0,\n    decoded_body_size_bytes:  numOrNull(nav.decodedBodySize),\n    device_memory_gb:         numOrNull(navigator.deviceMemory),\n    device_pixel_ratio:       numOrNull(window.devicePixelRatio),\n    do_not_track:             dnt,\n    dom_complete_ms:          numOrNull(nav.domComplete),\n    dom_content_loaded_end_ms:   numOrNull(nav.domContentLoadedEventEnd),\n    dom_content_loaded_start_ms: numOrNull(nav.domContentLoadedEventStart),\n    dom_interactive_ms:       numOrNull(nav.domInteractive),\n    domain_lookup_end_ms:     numOrNull(nav.domainLookupEnd),\n    domain_lookup_start_ms:   numOrNull(nav.domainLookupStart),\n    encoded_body_size_bytes:  numOrNull(nav.encodedBodySize),\n    external_link_count:      externalLinks,\n    form_count:               document.forms.length,\n    hardware_concurrency:     navigator.hardwareConcurrency || null,\n    has_json_ld:              hasJsonLd,\n    has_rss_feed:             hasRss,\n    iframe_count:             document.querySelectorAll('iframe').length,\n    image_count:              document.images.length,\n    is_online:                navigator.onLine ? 1 : 0,\n    is_reader_mode_eligible:  readerEligible,\n    json_ld_types:            jsonLdTypes,\n    lang:                     document.documentElement.lang || '',\n    last_modified:            document.lastModified || '',\n    link_count:               document.links.length,\n    load_event_end_ms:        numOrNull(nav.loadEventEnd),\n    load_event_start_ms:      numOrNull(nav.loadEventStart),\n    meta_description:         getMeta(['meta[name=\"description\"]', 'meta[property=\"og:description\"]']),\n    meta_generator:           getMeta(['meta[name=\"generator\"]']),\n    meta_robots:              getMeta(['meta[name=\"robots\"]']),\n    nav_type:                 nav.type || '',\n    next_hop_protocol:        nav.nextHopProtocol || '',\n    og_description:           getMeta(['meta[property=\"og:description\"]']),\n    og_image:                 getMeta(['meta[property=\"og:image\"]']),\n    og_site_name:             getMeta(['meta[property=\"og:site_name\"]']),\n    og_title:                 getMeta(['meta[property=\"og:title\"]']),\n    og_type:                  getMeta(['meta[property=\"og:type\"]']),\n    page_total_height_px:     document.documentElement.scrollHeight || 0,\n    redirect_count:           numOrNull(nav.redirectCount),\n    referrer:                 document.referrer || '',\n    request_start_ms:         numOrNull(nav.requestStart),\n    response_end_ms:          numOrNull(nav.responseEnd),\n    response_start_ms:        numOrNull(nav.responseStart),\n    screen_height_px:         window.screen.height || 0,\n    screen_width_px:          window.screen.width || 0,\n    script_count:             document.scripts.length,\n    secure_connection_start_ms: numOrNull(nav.secureConnectionStart),\n    tab_height_px:            window.outerHeight || 0,\n    tab_width_px:             window.outerWidth  || 0,\n    theme_color:              getMeta(['meta[name=\"theme-color\"]']),\n    transfer_size_bytes:      numOrNull(nav.transferSize),\n    twitter_card:             getMeta(['meta[name=\"twitter:card\"]']),\n    twitter_creator:          getMeta(['meta[name=\"twitter:creator\"]']),\n    unload_event_end_ms:      numOrNull(nav.unloadEventEnd),\n    unload_event_start_ms:    numOrNull(nav.unloadEventStart),\n    viewport_height_px:       window.innerHeight || 0,\n    viewport_width_px:        window.innerWidth  || 0,\n    word_count:               wordCount,\n    worker_start_ms:          numOrNull(nav.workerStart)\n  };\n}\n"
    },
    {
      "path": "bitacora/demo.csv",
      "url": "codigo/arquivos/bitacora/demo.csv",
      "downloadName": "demo.csv",
      "size": 856987,
      "mime": "text/csv",
      "text": null
    },
    {
      "path": "bitacora/icons/128.png",
      "url": "codigo/arquivos/bitacora/icons/128.png",
      "downloadName": "128.png",
      "size": 2174,
      "mime": "image/png",
      "text": null
    },
    {
      "path": "bitacora/icons/16.png",
      "url": "codigo/arquivos/bitacora/icons/16.png",
      "downloadName": "16.png",
      "size": 315,
      "mime": "image/png",
      "text": null
    },
    {
      "path": "bitacora/icons/48.png",
      "url": "codigo/arquivos/bitacora/icons/48.png",
      "downloadName": "48.png",
      "size": 817,
      "mime": "image/png",
      "text": null
    },
    {
      "path": "bitacora/LICENSE",
      "url": "codigo/arquivos/bitacora/LICENSE",
      "downloadName": "LICENSE",
      "size": 1089,
      "mime": "application/octet-stream",
      "text": null
    },
    {
      "path": "bitacora/manifest.json",
      "url": "codigo/arquivos/bitacora/manifest.json",
      "downloadName": "manifest.json",
      "size": 1102,
      "mime": "application/json",
      "text": "{\n  \"manifest_version\": 3,\n  \"name\": \"Bitácora\",\n  \"version\": \"1.0\",\n  \"description\": \"Registra y analiza metadatos de las webs visitadas para investigación y periodismo.\",\n  \"action\": {\n    \"default_title\": \"Abrir Bitácora\",\n    \"default_icon\": {\n      \"16\": \"icons/16.png\",\n      \"48\": \"icons/48.png\",\n      \"128\": \"icons/128.png\"\n    },\n    \"default_popup\": \"popup/popup.html\"\n  },\n  \"background\": {\n    \"service_worker\": \"background/service-worker.js\"\n  },\n  \"content_scripts\": [\n    {\n      \"matches\": [\n        \"<all_urls>\"\n      ],\n      \"js\": [\n        \"content/content.js\"\n      ],\n      \"run_at\": \"document_idle\"\n    }\n  ],\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self' 'wasm-unsafe-eval'; object-src 'self'\"\n  },\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"dashboard/vendor/sql-wasm.wasm\",\n        \"demo.csv\"\n      ],\n      \"matches\": [\n        \"<all_urls>\"\n      ]\n    }\n  ],\n  \"permissions\": [\n    \"storage\",\n    \"tabs\",\n    \"webNavigation\",\n    \"windows\"\n  ],\n  \"host_permissions\": [\n    \"<all_urls>\"\n  ],\n  \"incognito\": \"spanning\"\n}\n"
    },
    {
      "path": "bitacora/popup/popup.css",
      "url": "codigo/arquivos/bitacora/popup/popup.css",
      "downloadName": "popup.css",
      "size": 3358,
      "mime": "text/css",
      "text": "/*\n * Archivo: popup.css\n * Autores: Artai Bringas y Javier Rodríguez\n * Fecha: 26 de mayo del 2026\n * Función: Estilos del minimenú.\n * Licencia: MIT\n */\n\n* { box-sizing: border-box; margin: 0; padding: 0; }\n\nbody {\n  font-family: system-ui, -apple-system, sans-serif;\n  font-size: 13px;\n  color: #222;\n  width: 240px;\n  padding: 14px;\n  background: #fff;\n}\n\n.header {\n  margin-bottom: 14px;\n}\n\n.app-name {\n  font-weight: 700;\n  font-size: 13px;\n  color: #111;\n}\n\n.status-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 14px;\n  padding: 8px 10px;\n  background: #f5f5f5;\n  border-radius: 4px;\n}\n\n.dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  flex-shrink: 0;\n}\n\n.dot-recording { background: #2e7d32; }\n.dot-paused    { background: #bbb; }\n\n#status-label {\n  font-size: 13px;\n  font-weight: 600;\n}\n\n.toggle-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 6px;\n}\n\n.toggle-label { font-weight: 600; }\n\n.switch {\n  position: relative;\n  display: inline-block;\n  width: 38px;\n  height: 22px;\n}\n\n.switch input { opacity: 0; width: 0; height: 0; }\n\n.slider {\n  position: absolute;\n  cursor: pointer;\n  inset: 0;\n  background: #ccc;\n  border-radius: 22px;\n  transition: background 0.2s;\n}\n\n.slider::before {\n  content: '';\n  position: absolute;\n  width: 16px;\n  height: 16px;\n  left: 3px;\n  bottom: 3px;\n  background: #fff;\n  border-radius: 50%;\n  transition: transform 0.2s;\n}\n\ninput:checked + .slider { background: #1a73e8; }\ninput:checked + .slider::before { transform: translateX(16px); }\n\n.toggle-desc {\n  font-size: 12px;\n  color: #888;\n  line-height: 1.4;\n  margin-bottom: 12px;\n}\n\n.divider {\n  border-top: 1px solid #e0e0e0;\n  margin-bottom: 12px;\n}\n\n.btn-dashboard {\n  width: 100%;\n  padding: 7px 0;\n  font-family: inherit;\n  font-size: 13px;\n  background: #1a73e8;\n  color: #fff;\n  border: none;\n  border-radius: 4px;\n  cursor: pointer;\n  font-weight: 600;\n}\n\n.btn-dashboard:hover { background: #1557b0; }\n\n.section-title {\n  font-size: 11px;\n  font-weight: 700;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  color: #888;\n  margin-bottom: 6px;\n}\n.domain-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 4px;\n  margin-bottom: 8px;\n  min-height: 24px;\n}\n.tag-chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 2px 7px;\n  border-radius: 12px;\n  font-size: 12px;\n  color: #fff;\n  font-weight: 600;\n}\n.chip-x {\n  background: none;\n  border: none;\n  color: rgba(255,255,255,0.8);\n  cursor: pointer;\n  font-size: 11px;\n  padding: 0;\n  line-height: 1;\n}\n.chip-x:hover { color: #fff; }\n.no-tags { font-size: 12px; color: #aaa; }\n\n.add-tag-row {\n  display: flex;\n  gap: 6px;\n  margin-bottom: 4px;\n}\n.list-sel {\n  flex: 1;\n  font-family: inherit;\n  font-size: 12px;\n  border: 1px solid #bbb;\n  border-radius: 3px;\n  padding: 3px 6px;\n}\n.btn-add {\n  padding: 3px 10px;\n  font-family: inherit;\n  font-size: 12px;\n  background: #1a73e8;\n  color: #fff;\n  border: none;\n  border-radius: 3px;\n  cursor: pointer;\n  white-space: nowrap;\n}\n.btn-add:hover { background: #1557b0; }\n.tag-feedback {\n  font-size: 11px;\n  color: #2e7d32;\n  min-height: 14px;\n}\n.scope-row {\n  display: flex;\n  gap: 12px;\n  margin-bottom: 6px;\n}\n.scope-label {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  font-size: 11px;\n  color: #666;\n  cursor: pointer;\n}\n"
    },
    {
      "path": "bitacora/popup/popup.html",
      "url": "codigo/arquivos/bitacora/popup/popup.html",
      "downloadName": "popup.html",
      "size": 1597,
      "mime": "text/html",
      "text": "<!DOCTYPE html>\n\n<!--\n  Archivo: popup.html\n  Autores: Artai Bringas y Javier Rodríguez\n  Fecha: 26 de mayo del 2026\n  Función: Estructura HTML del minimenú que aparece al pulsar el icono de la extensión.\n  Licencia: MIT\n-->\n\n<html lang=\"es\">\n<head>\n  <meta charset=\"UTF-8\">\n  <title>Bitácora</title>\n  <link rel=\"stylesheet\" href=\"popup.css\">\n</head>\n<body>\n  <div class=\"header\"><span class=\"app-name\">Bitácora</span></div>\n\n  <div class=\"status-row\">\n    <div id=\"status-dot\" class=\"dot dot-recording\"></div>\n    <span id=\"status-label\">Grabando</span>\n    <label class=\"switch\" style=\"margin-left:auto\">\n      <input type=\"checkbox\" id=\"incognito-toggle\">\n      <span class=\"slider\"></span>\n    </label>\n  </div>\n\n  <div id=\"domain-section\" style=\"display:none\">\n    <div class=\"section-title\" id=\"current-domain-label\"></div>\n    <div id=\"domain-tags\" class=\"domain-tags\"></div>\n    <div class=\"add-tag-row\">\n      <select id=\"list-select\" class=\"list-sel\"></select>\n      <button class=\"btn-add\" id=\"add-to-list-btn\">+</button>\n    </div>\n    <div class=\"scope-row\">\n      <label class=\"scope-label\">\n        <input type=\"radio\" name=\"scope\" id=\"scope-select\" value=\"domain\" checked> Solo este dominio\n      </label>\n      <label class=\"scope-label\">\n        <input type=\"radio\" name=\"scope\" value=\"pattern\"> Dominio + subdominios\n      </label>\n    </div>\n    <div id=\"tag-feedback\" class=\"tag-feedback\"></div>\n  </div>\n\n  <div class=\"divider\"></div>\n  <button id=\"open-dashboard-btn\" class=\"btn-dashboard\">Abrir Bitácora</button>\n\n  <script src=\"popup.js\"></script>\n</body>\n</html>\n"
    },
    {
      "path": "bitacora/popup/popup.js",
      "url": "codigo/arquivos/bitacora/popup/popup.js",
      "downloadName": "popup.js",
      "size": 6459,
      "mime": "text/javascript",
      "text": "/*\n * Archivo: popup.js\n * Autores: Artai Bringas y Javier Rodríguez\n * Fecha: 26 de mayo del 2026\n * Función: Lógica del minimenú: toggle de grabación/pausa y gestión de etiquetas del dominio activo.\n * Licencia: MIT\n */\n\n'use strict';\nvar currentDomain = '';\nvar currentHostname = '';\nconst dot    = document.getElementById('status-dot');\nconst label  = document.getElementById('status-label');\nconst toggle = document.getElementById('incognito-toggle');\nfunction applyStatus(paused) {\n  toggle.checked    = paused;\n  dot.className     = 'dot ' + (paused ? 'dot-paused' : 'dot-recording');\n  label.textContent = paused ? 'Pausada' : 'Grabando';\n}\nchrome.storage.local.get('wmlIncognito', function(d) { applyStatus(!!d.wmlIncognito); });\ntoggle.addEventListener('change', function() {\n  chrome.storage.local.set({ wmlIncognito: toggle.checked });\n  applyStatus(toggle.checked);\n  chrome.action.setTitle({ title: toggle.checked ? 'Bitácora (pausada)' : 'Bitácora' });\n});\nchrome.tabs.query({ active: true, currentWindow: true }, async function(tabs) {\n  var tab = tabs && tabs[0];\n  if (!tab || !tab.url) return;\n  try {\n    var url = new URL(tab.url);\n    if (url.protocol !== 'http:' && url.protocol !== 'https:') return;\n    currentHostname = url.hostname;\n    currentDomain   = currentHostname;\n    document.getElementById('current-domain-label').textContent = currentHostname;\n    document.getElementById('domain-section').style.display = 'block';\n    await loadTagUI();\n  } catch(_) {}\n});\nasync function getTagLists() {\n  return new Promise(function(res) {\n    chrome.storage.local.get('wmlTagLists', function(d) { res(d.wmlTagLists || []); });\n  });\n}\nasync function saveTagLists(lists) {\n  return new Promise(function(res) { chrome.storage.local.set({ wmlTagLists: lists }, res); });\n}\nfunction matchesDomain(domain, entry) {\nif (entry.type === 'domain') {\n  var norm = function(d) { return d.trim().replace(/^https?:\\/\\//, '').replace(/^www\\./, '').split('/')[0].split('?')[0].toLowerCase(); };\n  return norm(domain) === norm(entry.value);\n}\n  if (entry.type === 'pattern') {\n    var pat = entry.value.replace(/^\\*\\./, '');\n    return domain === pat || domain.endsWith('.' + pat);\n  }\n  return false;\n}\nasync function loadTagUI() {\n  var lists    = await getTagLists();\n  var tagsEl   = document.getElementById('domain-tags');\n  var sel      = document.getElementById('list-select');\n  var scopeSel = document.querySelector('input[name=\"scope\"]:checked');\n  var fb       = document.getElementById('tag-feedback');\n  if (fb) fb.textContent = '';\n  var memberOf  = lists.filter(function(l) { return (l.entries||[]).some(function(e) { return matchesDomain(currentHostname, e); }); });\n  var available = lists.filter(function(l) { return !(l.entries||[]).some(function(e) { return matchesDomain(currentHostname, e); }); });\n  tagsEl.innerHTML = memberOf.length\n    ? memberOf.map(function(l) {\n        return '<span class=\"tag-chip\" style=\"background:' + (l.color||'#1a73e8') + '\">' +\n          esc(l.name) + '<button class=\"chip-x\" data-id=\"' + l.id + '\">✕</button></span>';\n      }).join('')\n    : '<span class=\"no-tags\">Sin listas asignadas</span>';\n  tagsEl.querySelectorAll('.chip-x').forEach(function(btn) {\n    btn.addEventListener('click', async function() {\n      var lists2 = await getTagLists();\n      var found  = lists2.find(function(l) { return l.id === btn.dataset.id; });\n      if (!found) return;\n      found.entries = (found.entries||[]).filter(function(e) { return !matchesDomain(currentHostname, e); });\n      await saveTagLists(lists2);\n      if (fb) fb.textContent = 'Eliminado de \"' + found.name + '\"';\n      await loadTagUI();\n    });\n  });\n  sel.innerHTML =\n    '<option value=\"\">Añadir a lista…</option>' +\n    available.map(function(l) { return '<option value=\"' + l.id + '\">' + esc(l.name) + '</option>'; }).join('') +\n    '<option value=\"__new__\">+ Nueva lista…</option>';\n}\ndocument.getElementById('add-to-list-btn').addEventListener('click', async function() {\n  var sel      = document.getElementById('list-select');\n  var scopeSel = document.querySelector('input[name=\"scope\"]:checked');\n  var fb       = document.getElementById('tag-feedback');\n  var val      = sel.value;\n  var scope    = scopeSel ? scopeSel.value : 'domain';\n  if (!val) return;\n  var entryValue = (scope === 'pattern') ? ('*.' + currentHostname) : currentHostname;\n  var entryType  = scope;\n  var lists = await getTagLists();\n  if (val === '__new__') {\n    var existingForm = document.getElementById('new-list-form');\n    if (existingForm) { existingForm.querySelector('#new-list-input').focus(); return; }\n    var form = document.createElement('div');\n    form.id = 'new-list-form';\n    form.style.cssText = 'display:flex;gap:4px;margin-top:4px';\n    form.innerHTML = '<input id=\"new-list-input\" type=\"text\" class=\"list-sel\" placeholder=\"Nombre de la lista\" style=\"flex:1\">' +\n      '<button id=\"new-list-ok\" class=\"btn-add\">✓</button>';\n    document.getElementById('tag-feedback').before(form);\n    var inp = form.querySelector('#new-list-input');\n    inp.focus();\n    async function submitNewList() {\n      var name = inp.value.trim();\n      if (!name) return;\n      form.remove();\n      var lists2 = await getTagLists();\n      lists2.push({ id: Date.now().toString(36), name: name, color: '#1a73e8', entries: [{ type: entryType, value: entryValue }] });\n      await saveTagLists(lists2);\n      if (fb) fb.textContent = 'Lista \"' + esc(name) + '\" creada y dominio añadido.';\n      await loadTagUI();\n    }\n    form.querySelector('#new-list-ok').addEventListener('click', submitNewList);\n    inp.addEventListener('keydown', function(e) { if (e.key === 'Enter') submitNewList(); if (e.key === 'Escape') form.remove(); });\n    return;\n  } else {\n    var found = lists.find(function(l) { return l.id === val; });\n    if (!found) return;\n    (found.entries = found.entries||[]).push({ type: entryType, value: entryValue });\n    if (fb) fb.textContent = 'Añadido a \"' + found.name + '\" (' + (scope === 'pattern' ? 'dominio + subdominios' : 'solo dominio') + ')';\n  }\n  await saveTagLists(lists);\n  await loadTagUI();\n});\ndocument.getElementById('open-dashboard-btn').addEventListener('click', function() {\n  chrome.tabs.create({ url: chrome.runtime.getURL('dashboard/index.html') });\n  window.close();\n});\nfunction esc(s) {\n  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n}\n"
    },
    {
      "path": "bitacora/README.md",
      "url": "codigo/arquivos/bitacora/README.md",
      "downloadName": "README.md",
      "size": 4240,
      "mime": "text/markdown",
      "text": "# BITÁCORA\n\n## AUTORÍA Y DESARROLLO\n\nAutores: Artai Bringas y Javier Rodríguez\nFecha: 26 de mayo del 2026\nLicencia: MIT\n\nDesarrollo asistido por inteligencia artificial (ChatGPT-5.2 Thinking High a través de Github Copilot Pro en la interfaz de Visual Studio Code)\n\nEl proyecto Bitácora se enmarca en la asignatura Xornalismo Automatizado Intelixente\nFacultade de Ciencias da Comunicación - Universidade de Santiago de Compostela\nCurso 2025-2026\n\n\n## FUNCIONALIDAD\n\nBitácora es una extensión de Chrome para el registro automático de metadatos de navegación web.\n\nCaptura automáticamente 115 campos de metadatos por cada página web visitada: URL, título, dominio, tiempo de permanencia, tiempos de carga (PerformanceNavigationTiming), datos de red, metadatos Open Graph, información del artículo (autor, sección, fechas), datos del dispositivo y entorno del navegador, y campos editables por el usuario (notas y etiquetas). La captura ocurre en segundo plano sin intervención del usuario.\n\nLos datos se almacenan en archivos CSV locales. El panel de análisis permite:\n\n  - Consulta visual: filtros por campo con operadores AND/OR y negación (NO),\n    sin necesidad de escribir SQL.\n  - Consulta SQL: consultas SELECT directas sobre la tabla \"records\"\n    con SQLite en memoria.\n  - Etiquetas: clasificación de dominios mediante listas con soporte de\n    wildcards (*.dominio.com), aplicables retroactiva y automáticamente.\n  - Estadísticas: resumen de la actividad de navegación filtrable por período\n    (7 días, mes, 3 meses, año, todo).\n  - Exportación: CSV plano o archivo .bit cifrado con AES-256-GCM.\n\nEl auto-guardado escribe en disco con cada visita capturada, también con el\npanel cerrado. Los archivos .bit se trabajan en memoria y se descifran al\nabrirse.\n\n\n## INSTALACIÓN\n\n1. Descomprimir el archivo .zip\n2. Abrir chrome://extensions en Chrome\n3. Activar \"Modo desarrollador\"\n4. Pulsar \"Cargar descomprimida\" y seleccionar la carpeta del proyecto\n\n\n## PRIVACIDAD\n\nBitácora no envía ningún dato a servidores externos. Toda la información\ncapturada permanece en el dispositivo del usuario, en los archivos CSV que\nél mismo gestiona.\n\n\n## DEPENDENCIAS DE TERCEROS\n\nsql.js (v1.14.1) — Licencia MIT\n  Motor SQLite compilado a WebAssembly para ejecutar consultas en memoria.\n  https://github.com/sql-js/sql.js\n\nPapaParse (v5.5.3) — Licencia MIT\n  Parsing y generación de archivos CSV.\n  https://github.com/mholt/PapaParse\n\nEl resto del código (service worker, content script, panel de análisis,\nmódulo de cifrado, estilos) es original.\n\n\n## CIFRADO\n\nEl cifrado de archivos .bit usa la Web Crypto API nativa del navegador:\n\n  - Algoritmo: AES-256-GCM\n  - Derivación de clave: PBKDF2 con SHA-256, 200.000 iteraciones\n  - Salt e IV generados aleatoriamente por archivo\n\nNo existe mecanismo de recuperación si se pierde la contraseña.\n\n\n## AVISOS\n\nAlgunos antivirus pueden marcar la extensión como sospechosa durante la instalación o el uso. Se trata de un falso positivo: Bitácora no contiene código malicioso. El código fuente es completamente auditable.\n\nEntre las posibles causas de la alerta figuran: la instalación en modo desarrollador sin pasar por la Chrome Web Store; el acceso a todas las páginas web mediante el content script; la escritura de archivos en disco a través de la File System Access API; la captura sistemática de metadatos de navegación; o la presencia de código de cifrado y de código WebAssembly (sql.js). Ninguno de estos elementos implica comportamiento malicioso: la extensión no realiza peticiones de red a servidores externos, no ejecuta código remoto, no persiste en el sistema sin consentimiento explícito del usuario, y los metadatos capturados y los archivos cifrados se almacenan exclusivamente en local. Los permisos que declara en el manifest son los mínimos necesarios para la funcionalidad descrita. El código fuente, al instalarse en modo desarrollador, es completamente auditable antes de conceder ningún permiso.\n\n---\n\nProyecto de la asignatura Xornalismo Automatizado Intelixente  \nFacultade de Ciencias da Comunicación · Universidade de Santiago de Compostela  \nCurso 2025-2026 | [ia.xornalismo.gal](https://ia.xornalismo.gal)\n"
    },
    {
      "path": "bitacora/readme.txt",
      "url": "codigo/arquivos/bitacora/readme.txt",
      "downloadName": "readme.txt",
      "size": 4100,
      "mime": "text/plain",
      "text": "BITÁCORA\n========\n\nAUTORÍA Y DESARROLLO\n-------------\n\nAutores: Artai Bringas y Javier Rodríguez\nFecha: 26 de mayo del 2026\nLicencia: MIT\n\nDesarrollo asistido por inteligencia artificial (ChatGPT-5.2 Thinking High a través de Github Copilot Pro en la interfaz de Visual Studio Code)\n\nEl proyecto Bitácora se enmarca en la asignatura Xornalismo Automatizado Intelixente\nFacultade de Ciencias da Comunicación - Universidade de Santiago de Compostela\nCurso 2025-2026\n\n\nFUNCIONALIDAD\n-------------\n\nBitácora es una extensión de Chrome para el registro automático de metadatos de navegación web.\n\nCaptura automáticamente 115 campos de metadatos por cada página web visitada: URL, título, dominio, tiempo de permanencia, tiempos de carga (PerformanceNavigationTiming), datos de red, metadatos Open Graph, información del artículo (autor, sección, fechas), datos del dispositivo y entorno del navegador, y campos editables por el usuario (notas y etiquetas). La captura ocurre en segundo plano sin intervención del usuario.\n\nLos datos se almacenan en archivos CSV locales. El panel de análisis permite:\n\n  - Consulta visual: filtros por campo con operadores AND/OR y negación (NO),\n    sin necesidad de escribir SQL.\n  - Consulta SQL: consultas SELECT directas sobre la tabla \"records\"\n    con SQLite en memoria.\n  - Etiquetas: clasificación de dominios mediante listas con soporte de\n    wildcards (*.dominio.com), aplicables retroactiva y automáticamente.\n  - Estadísticas: resumen de la actividad de navegación filtrable por período\n    (7 días, mes, 3 meses, año, todo).\n  - Exportación: CSV plano o archivo .bit cifrado con AES-256-GCM.\n\nEl auto-guardado escribe en disco con cada visita capturada, también con el\npanel cerrado. Los archivos .bit se trabajan en memoria y se descifran al\nabrirse.\n\n\nINSTALACIÓN\n-----------\n\n1. Descomprimir el archivo .zip\n2. Abrir chrome://extensions en Chrome\n3. Activar \"Modo desarrollador\"\n4. Pulsar \"Cargar descomprimida\" y seleccionar la carpeta del proyecto\n\n\nPRIVACIDAD\n----------\n\nBitácora no envía ningún dato a servidores externos. Toda la información\ncapturada permanece en el dispositivo del usuario, en los archivos CSV que\nél mismo gestiona.\n\n\nDEPENDENCIAS DE TERCEROS\n------------------------\n\nsql.js (v1.14.1) — Licencia MIT\n  Motor SQLite compilado a WebAssembly para ejecutar consultas en memoria.\n  https://github.com/sql-js/sql.js\n\nPapaParse (v5.5.3) — Licencia MIT\n  Parsing y generación de archivos CSV.\n  https://github.com/mholt/PapaParse\n\nEl resto del código (service worker, content script, panel de análisis,\nmódulo de cifrado, estilos) es original.\n\n\nCIFRADO\n-------\n\nEl cifrado de archivos .bit usa la Web Crypto API nativa del navegador:\n\n  - Algoritmo: AES-256-GCM\n  - Derivación de clave: PBKDF2 con SHA-256, 200.000 iteraciones\n  - Salt e IV generados aleatoriamente por archivo\n\nNo existe mecanismo de recuperación si se pierde la contraseña.\n\n\nAVISOS\n-------\n\nAlgunos antivirus pueden marcar la extensión como sospechosa durante la instalación o el uso. Se trata de un falso positivo: Bitácora no contiene código malicioso. El código fuente es completamente auditable.\n\nEntre las posibles causas de la alerta figuran: la instalación en modo desarrollador sin pasar por la Chrome Web Store; el acceso a todas las páginas web mediante el content script; la escritura de archivos en disco a través de la File System Access API; la captura sistemática de metadatos de navegación; o la presencia de código de cifrado y de código WebAssembly (sql.js). Ninguno de estos elementos implica comportamiento malicioso: la extensión no realiza peticiones de red a servidores externos, no ejecuta código remoto, no persiste en el sistema sin consentimiento explícito del usuario, y los metadatos capturados y los archivos cifrados se almacenan exclusivamente en local. Los permisos que declara en el manifest son los mínimos necesarios para la funcionalidad descrita. El código fuente, al instalarse en modo desarrollador, es completamente auditable antes de conceder ningún permiso.\n"
    },
    {
      "path": "bitacora/requirements.txt",
      "url": "codigo/arquivos/bitacora/requirements.txt",
      "downloadName": "requirements.txt",
      "size": 225,
      "mime": "text/plain",
      "text": "# Bitácora — dependencias de terceros\n\n# Incluidas en dashboard/vendor/ (no requiere instalación):\n# sql.js 1.14.1 (MIT) — https://github.com/sql-js/sql.js\n# PapaParse 5.5.3 (MIT) — https://github.com/mholt/PapaParse\n"
    }
  ]
}